Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9f8380ece | ||
|
|
10e1504531 | ||
|
|
2e3464104d | ||
|
|
dc8f9c5099 | ||
|
|
08905a1680 | ||
|
|
fd939797dd | ||
|
|
a621a3ee62 | ||
|
|
ae4a56dac1 | ||
|
|
fdc2378191 | ||
|
|
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 | ||
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 | ||
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d | ||
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 | ||
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 | ||
|
|
91b5af1c7b | ||
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 | ||
|
|
3e7f3832e0 | ||
|
|
34b60f9f20 | ||
|
|
b7bfd39652 | ||
|
|
6a1103bf5f | ||
|
|
91653bca57 | ||
|
|
6be0f43dd0 | ||
|
|
370fde42f7 | ||
|
|
e5ff30823d | ||
|
|
9e86d57dac | ||
|
|
00bfee4ed2 | ||
|
|
f4bc55cd41 | ||
|
|
3a9dda13c4 | ||
|
|
9b677c6b35 | ||
|
|
933d601c03 | ||
|
|
b4f0e0bc29 | ||
|
|
9f384402fa | ||
|
|
18b69ee8b4 | ||
|
|
8c1b7af5b3 | ||
|
|
6e953ab1f4 | ||
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 | ||
|
|
384925f24a | ||
|
|
898323c49f | ||
|
|
48345a22fc | ||
|
|
b83d55cc32 | ||
|
|
76e4288b9e | ||
|
|
246ecf23ba | ||
|
|
1717677056 | ||
|
|
15159f38a5 | ||
|
|
6a28344e93 | ||
|
|
a14ade9277 | ||
|
|
513ab178ec | ||
|
|
e3aeeae616 | ||
|
|
a5cfecbf76 | ||
|
|
7d644d05cc | ||
|
|
75510a1161 | ||
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 | ||
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc | ||
|
|
f665d666d6 | ||
|
|
5f9ee9efa2 | ||
|
|
44d276c841 | ||
|
|
e656c79589 | ||
|
|
86cbe94cae | ||
|
|
b9b005ea36 | ||
|
|
2c47c5d052 | ||
|
|
446888a330 | ||
|
|
128364260b | ||
|
|
daf38554a5 | ||
|
|
39f095ae75 | ||
|
|
ea8e954eef | ||
|
|
6db90abcad | ||
|
|
6b9307b427 | ||
|
|
ab06673854 | ||
|
|
1cadefd207 | ||
|
|
05fbb8a3bc | ||
|
|
3d88d8f50f | ||
|
|
5b37397a64 | ||
|
|
1184a675c2 | ||
|
|
7a24282aa4 | ||
|
|
701e8a2c25 | ||
|
|
c4ab935d5f | ||
|
|
be00f6a46d | ||
|
|
7b0a1ac832 | ||
|
|
06183bd5d4 | ||
|
|
fafa0c22ab | ||
|
|
4f32012930 | ||
|
|
a8b3269b1e | ||
|
|
25a53e4110 | ||
|
|
8cf53a0aa2 | ||
|
|
e112de5967 | ||
|
|
be48e58aa6 | ||
|
|
565c143536 | ||
|
|
2ce25339ab | ||
|
|
121ecae314 | ||
|
|
dd0a34dc0a | ||
|
|
45b9bcdea7 | ||
|
|
2d742be7df | ||
|
|
3a6afc28ac | ||
|
|
e590a58702 | ||
|
|
dd4b0004a5 | ||
|
|
abbdde9367 | ||
|
|
fda21e5a6f | ||
|
|
ee9fb51066 | ||
|
|
5bbcaab71a | ||
|
|
18438d7737 | ||
|
|
97e24ea24f | ||
|
|
3e199f9ab6 | ||
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e | ||
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab | ||
|
|
64f2d38d87 | ||
|
|
299184712a | ||
|
|
76c1e2df60 | ||
|
|
165f33caa5 | ||
|
|
464a1c702c | ||
|
|
19c5045dc6 | ||
|
|
fa09251039 | ||
|
|
d6626d96d0 | ||
|
|
8b831145ad | ||
|
|
81c60628c6 | ||
|
|
678787ec62 | ||
|
|
79dc20a859 | ||
|
|
824971d0a1 | ||
|
|
60bcd2422d | ||
|
|
9b0d7ce1dc | ||
|
|
572e8ca538 | ||
|
|
6ac9783f7c | ||
|
|
725600c341 | ||
|
|
5d9765be09 | ||
|
|
b302d4d87b | ||
|
|
8b7c42ec9b | ||
|
|
cde0add5e0 | ||
|
|
0e2ef317c3 | ||
|
|
a9f2e515e1 | ||
|
|
260172cd6d | ||
|
|
4b5e2e0b72 | ||
|
|
4a6ea45665 | ||
|
|
95d37da3bb | ||
|
|
e1b3f0faf3 | ||
|
|
6379e2cd1f | ||
|
|
2228816057 | ||
|
|
32878c17be | ||
|
|
079d0c32df | ||
|
|
45d081ac0c | ||
|
|
183db7ac2b | ||
|
|
4d074de27e | ||
|
|
679e8f8d39 | ||
|
|
dd2deee939 | ||
|
|
cdd71b17c8 | ||
|
|
e8eedcc1dc | ||
|
|
3c47366f56 | ||
|
|
bd11bb4763 |
@@ -39,9 +39,11 @@ desktop.ini
|
|||||||
hamlog.db*
|
hamlog.db*
|
||||||
cty.dat
|
cty.dat
|
||||||
cat.log
|
cat.log
|
||||||
|
*.adi
|
||||||
|
|
||||||
# --- Secrets ---
|
# --- Secrets ---
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
*.syso
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
|
||||||
|
|
||||||
|
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
|
||||||
|
wails build # full build → build/bin/OpsLog.exe (~25 s)
|
||||||
|
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
|
||||||
|
|
||||||
|
go build ./... # fast Go-only check
|
||||||
|
go test ./... # all Go tests
|
||||||
|
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
|
||||||
|
gofmt -w <file>
|
||||||
|
|
||||||
|
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
|
||||||
|
```
|
||||||
|
|
||||||
|
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* → *Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
|
||||||
|
|
||||||
|
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
|
||||||
|
|
||||||
|
## Navigating app.go
|
||||||
|
|
||||||
|
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "^// ──\|^// ---" app.go # table of contents
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
|
||||||
|
|
||||||
|
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Wails binding boundary
|
||||||
|
|
||||||
|
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
|
||||||
|
|
||||||
|
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
|
||||||
|
|
||||||
|
### Two separate databases
|
||||||
|
|
||||||
|
This split is easy to get wrong and matters:
|
||||||
|
|
||||||
|
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
|
||||||
|
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
|
||||||
|
|
||||||
|
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
|
||||||
|
|
||||||
|
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
|
||||||
|
|
||||||
|
### Per-profile settings scoping
|
||||||
|
|
||||||
|
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
|
||||||
|
|
||||||
|
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
|
||||||
|
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
|
||||||
|
|
||||||
|
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
|
||||||
|
|
||||||
|
### Hardware device pattern
|
||||||
|
|
||||||
|
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
|
||||||
|
|
||||||
|
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
|
||||||
|
2. **Settings** — `key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
|
||||||
|
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
|
||||||
|
4. **Status binding** — `Get<Thing>Status()`, polled by the frontend.
|
||||||
|
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
|
||||||
|
|
||||||
|
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
|
||||||
|
|
||||||
|
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
|
||||||
|
|
||||||
|
### CAT
|
||||||
|
|
||||||
|
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
**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`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
|
||||||
|
|
||||||
|
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
|
||||||
|
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
|
||||||
|
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
|
||||||
|
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
# OpsLog
|
||||||
|
|
||||||
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
|
||||||
|
bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||||
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
|
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||||
|
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||||
|
concepteur de cartes QSL. Construit avec **Wails v2** (backend Go + frontend
|
||||||
|
React/TypeScript), **100 % Go** (sans CGO) : SQLite pour la configuration,
|
||||||
|
**MySQL partagé** optionnel pour le journal afin que plusieurs opérateurs
|
||||||
|
partagent un même log. Entièrement thémable et bilingue (anglais / français).
|
||||||
|
|
||||||
|
Développé par **F4BPO**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilation / développement
|
||||||
|
|
||||||
|
- **Dev :** `wails dev` (rechargement à chaud Vite ; méthodes Go accessibles sur http://localhost:34115).
|
||||||
|
- **Build :** `wails build` (utiliser la version wails v2.11 du projet — `~/go/bin/wails.exe`).
|
||||||
|
- **Régénérer les bindings Go↔TS** après modification des méthodes `App`
|
||||||
|
exportées : `wails generate module`.
|
||||||
|
- **Release :** `.vscode/release.ps1` (Ctrl+Maj+P → *Tasks: Run Task* →
|
||||||
|
*Release OpsLog*) — incrémente la version, pousse le source sur Gitea, compile
|
||||||
|
l'exe et le publie sur les releases Gitea + GitHub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journalisation
|
||||||
|
|
||||||
|
- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||||
|
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||||
|
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||||
|
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||||
|
nom/QTH/locator et onglet QRZ.com.
|
||||||
|
- **Résolution DXCC hors ligne** depuis `cty.dat` (pays, zones CQ/ITU,
|
||||||
|
continent), avec gestion des `/MM` `/AM` `/B` (balise) et des changements de
|
||||||
|
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||||
|
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||||
|
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||||
|
QSL. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
|
||||||
|
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
|
||||||
|
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
|
||||||
|
sont montrées, pas seulement la première page).
|
||||||
|
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||||
|
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||||
|
sélectionnées.
|
||||||
|
- **Recherche de doublons** (Outils) — regroupe les QSO par même indicatif +
|
||||||
|
bande + mode (optionnellement même jour / minute) et permet de choisir lesquels
|
||||||
|
supprimer.
|
||||||
|
- **Conforme ADIF 3.1.7** en import/export : dictionnaire complet des champs,
|
||||||
|
30 colonnes promues, éditeur générique de « champs supplémentaires » et modes
|
||||||
|
d'export standard/complet.
|
||||||
|
- **Profils :** chaque réglage est par profil ; chaque profil peut pointer son
|
||||||
|
journal vers le fichier SQLite local ou une base **MySQL partagée**
|
||||||
|
(multi-opérateur).
|
||||||
|
|
||||||
|
## Cartes & antenne
|
||||||
|
|
||||||
|
- **Vue principale = deux volets configurables** (par profil, Réglages → Général
|
||||||
|
→ *Vue principale*) : carte grand-cercle, carte locator (rue), la grille du
|
||||||
|
cluster, la grille des déjà-contactés, les QSO récents, les **commandes
|
||||||
|
FlexRadio**, la **console Icom** ou le panneau **Net control**.
|
||||||
|
- **Carte grand-cercle** avec distance & azimut trajet court/long, fonds de carte
|
||||||
|
sélectionnables (Light / Voyager / Street / Satellite, tous sans clé et
|
||||||
|
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||||
|
rotor.
|
||||||
|
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||||
|
**PstRotator** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
|
||||||
|
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
|
||||||
|
avec son protocole Yaesu GS-232A.
|
||||||
|
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||||
|
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||||
|
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||||
|
|
||||||
|
## Cluster DX
|
||||||
|
|
||||||
|
- Plusieurs serveurs de cluster avec reconnexion auto, un maître pour les
|
||||||
|
commandes.
|
||||||
|
- **Barre latérale de filtres** (recherche d'indicatif, masquer-déjà-contactés,
|
||||||
|
grouper les doublons, bande / mode / statut / source) partagée entre l'onglet
|
||||||
|
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||||
|
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||||
|
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||||
|
panadapter). En option, tous les **modes numériques comptent comme un seul**
|
||||||
|
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
|
||||||
|
matrice des déjà-contactés (Réglages → Général).
|
||||||
|
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||||
|
`api.pota.app`).
|
||||||
|
- **Alertes de spots :** règles sur indicatif / pays / bande /
|
||||||
|
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||||
|
*Gestion des alertes*).
|
||||||
|
|
||||||
|
## Contrôle CAT
|
||||||
|
|
||||||
|
Quatre backends natifs (Réglages → CAT), chacun avec reconnexion auto et une
|
||||||
|
connexion rapide non bloquante — une radio éteinte ne fige jamais l'application :
|
||||||
|
|
||||||
|
- **OmniRig** (Rig 1/2, changement à chaud) — fonctionne avec toute radio
|
||||||
|
supportée par OmniRig.
|
||||||
|
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||||
|
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||||
|
du cluster poussés sur l'écran Flex ; un clic remplit l'indicatif et **accorde
|
||||||
|
la radio sur la bonne fréquence ET le bon mode**).
|
||||||
|
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||||
|
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||||
|
RS-BA1 ni Remote Utility nécessaires.
|
||||||
|
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 et tout serveur compatible TCI :
|
||||||
|
fréquence / mode / PTT / split, plus spots panorama optionnels.
|
||||||
|
|
||||||
|
Le mode est lu depuis la radio ; le sous-mode numérique (FT4 vs FT8) est déduit
|
||||||
|
de la fréquence. Les **antennes RX/TX Flex par bande** sont configurables et
|
||||||
|
appliquées automatiquement au changement de bande.
|
||||||
|
|
||||||
|
### Onglet de commande FlexRadio (façon SmartSDR)
|
||||||
|
|
||||||
|
Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||||
|
|
||||||
|
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||||
|
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||||
|
- **Réception (slice active) :** sélecteurs d'**antenne** RX/TX et une bascule
|
||||||
|
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
|
||||||
|
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
|
||||||
|
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
|
||||||
|
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
|
||||||
|
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
|
||||||
|
molette / ±.
|
||||||
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
|
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
|
||||||
|
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
|
||||||
|
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
|
||||||
|
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||||
|
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||||
|
|
||||||
|
### Onglet de commande Icom
|
||||||
|
|
||||||
|
Affiché quand le backend CAT est Icom (USB ou réseau). Une console complète façon
|
||||||
|
RS-BA1 :
|
||||||
|
|
||||||
|
- **Double afficheur VFO** (MAIN / SUB) avec la grande fréquence tabulaire, le
|
||||||
|
badge de mode, la bande et l'offset RIT/ΔTX, et une **rangée de boutons de
|
||||||
|
mode** (SSB / CW / RTTY / PSK / AM / FM).
|
||||||
|
- **Scope de spectre + waterfall** (panadapter) : ON/OFF, CTR/FIX, double-clic
|
||||||
|
pour accorder, et boutons **◀ ⊙ ▶** pour centrer le scope sur la fréquence
|
||||||
|
actuelle (±50 kHz) et le décaler à gauche/droite.
|
||||||
|
- **Mesures en direct** toujours visibles : S-mètre (clic → remplir le RST),
|
||||||
|
puissance en watts, ROS.
|
||||||
|
- **DSP réception :** gain AF / RF, squelch, AGC, préampli, atténuateur, filtre
|
||||||
|
(FIL1/2/3), NB, NR, ANF et — **en CW seulement** — l'**APF** (filtre de pic
|
||||||
|
audio).
|
||||||
|
- **Passe-bande / notch :** Twin PBT (intérieur / extérieur), notch manuel +
|
||||||
|
position.
|
||||||
|
- **Émission :** puissance RF, MOX, TUNE, **split avec offset automatique**
|
||||||
|
(+5 kHz en SSB, +1 kHz en CW), et moniteur. **En phonie seulement** : gain
|
||||||
|
micro, processeur de parole, VOX (+ gain + anti-VOX). Les commandes qui ne
|
||||||
|
s'appliquent pas au mode courant sont masquées automatiquement.
|
||||||
|
- **Bandes & antenne :** boutons de bande en un clic et sélection ANT1/ANT2.
|
||||||
|
- **Clarificateurs :** RIT et ΔTX avec accord molette / ± (Ctrl+←/→ décale le
|
||||||
|
RIT).
|
||||||
|
- Boutons **Marche / Arrêt** (manuels par choix — l'application ne réveille
|
||||||
|
jamais la radio à la connexion).
|
||||||
|
- La **manipulation CW** peut passer par le keyer intégré de la radio (voir
|
||||||
|
*Keyers* ci-dessous).
|
||||||
|
|
||||||
|
### Icom à distance (par internet, sans RS-BA1)
|
||||||
|
|
||||||
|
OpsLog parle directement le protocole réseau intégré de l'IC-7610 — il
|
||||||
|
**remplace à la fois l'Icom Remote Utility et RS-BA1**. Saisissez l'IP de la
|
||||||
|
radio, le nom/mot de passe Network User1 et l'adresse CI-V, et toute la console
|
||||||
|
Icom fonctionne sur le LAN/internet : login + token (renouvelé automatiquement),
|
||||||
|
tunnel CI-V, retransmission côté réception pour une liaison très stable même
|
||||||
|
avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmètre
|
||||||
|
— utilisez la radio en USB + une liaison voix comme Mumble.)
|
||||||
|
|
||||||
|
## Keyers & audio
|
||||||
|
|
||||||
|
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||||
|
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||||
|
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||||
|
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
|
||||||
|
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
|
||||||
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||||
|
émettre.
|
||||||
|
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||||
|
*Log QSO* le contact est sauvegardé dans un WAV par QSO
|
||||||
|
(`INDICATIF_AAAAMMJJ_HHMMSS.wav`) ; mixe RX + micro.
|
||||||
|
|
||||||
|
## Amplis & commutateurs
|
||||||
|
|
||||||
|
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||||
|
Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
|
||||||
|
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
|
||||||
|
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
|
||||||
|
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
|
||||||
|
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
|
||||||
|
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||||
|
ventilateur et affichage des défauts.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||||
|
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||||
|
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||||
|
ROS, courant PA, température, avertissements/alarmes).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
|
||||||
|
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
|
||||||
|
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
|
||||||
|
défauts). La mise en marche fonctionne via un câble série reliant les lignes
|
||||||
|
DTR/RTS.
|
||||||
|
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||||
|
commutation A/B ancré.
|
||||||
|
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||||
|
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||||
|
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||||
|
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||||
|
antennes et accessoires de la station.
|
||||||
|
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||||
|
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||||
|
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||||
|
|
||||||
|
## QSL & diplômes
|
||||||
|
|
||||||
|
- **Moteur de diplômes :** définitions intégrées + personnalisées (partagées
|
||||||
|
**globalement** entre profils) — DXCC, WAS / WAZ / WAC, WPX,
|
||||||
|
IOTA / POTA / SOTA / WWFF, **DDFM**, contacté/confirmé/validé par bande & mode,
|
||||||
|
règles OU et affectation manuelle de références, détection de référence en
|
||||||
|
direct à la saisie de l'indicatif, **import de listes de références** pour les
|
||||||
|
totaux/noms, et un **Rescan** qui relit le journal (récupère les nouvelles
|
||||||
|
confirmations LoTW/QRZ).
|
||||||
|
- **Services QSL :** ClubLog (upload ADIF par lots), LoTW, QRZ.com, eQSL —
|
||||||
|
upload et **téléchargement des confirmations** (qui rafraîchit automatiquement
|
||||||
|
les stats de diplômes).
|
||||||
|
- **Concepteur de cartes QSL** (voir ci-dessous).
|
||||||
|
- **eQSL par e-mail :** clic droit sur un QSO → *Envoyer l'eQSL par e-mail* via
|
||||||
|
le compte SMTP configuré. (Outlook/Hotmail désactivent le SMTP basic-auth —
|
||||||
|
utilisez Gmail avec un mot de passe d'application, ou un mot de passe
|
||||||
|
d'application Microsoft.)
|
||||||
|
|
||||||
|
## Log de concours
|
||||||
|
|
||||||
|
- **Onglet Contest :** choisissez un concours (liste ADIF `CONTEST_ID` intégrée)
|
||||||
|
et un échange (numéro de série courant ou échange fixe). OpsLog remplit
|
||||||
|
automatiquement `CONTEST_ID` et les numéros émis/reçus (`STX` / `SRX`), impose
|
||||||
|
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||||
|
direct.
|
||||||
|
|
||||||
|
## Statistiques
|
||||||
|
|
||||||
|
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||||
|
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||||
|
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||||
|
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||||
|
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||||
|
**Table** qui reprend chaque graphique.
|
||||||
|
|
||||||
|
## Statut opérateur en direct (événements spéciaux)
|
||||||
|
|
||||||
|
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||||
|
**TM74TFR**), la publication est **automatique** — aucun réglage à activer.
|
||||||
|
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||||
|
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||||
|
~15 s, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
|
||||||
|
logué. Un petit rendu PHP
|
||||||
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||||
|
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||||
|
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||||
|
(`<img src="…/tm74-status.php?img=1">`). OpsLog écrit seulement dans la base —
|
||||||
|
ce n'est pas un serveur web.
|
||||||
|
|
||||||
|
## Net control
|
||||||
|
|
||||||
|
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||||
|
session active en mémoire — pointez les stations présentes, puis loguez-les une
|
||||||
|
par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
|
||||||
|
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
|
||||||
|
un QSO, on air → roster l'enregistre), et après chaque log la station on air
|
||||||
|
suivante est sélectionnée automatiquement pour enchaîner les contacts.
|
||||||
|
|
||||||
|
## Apparence & langue
|
||||||
|
|
||||||
|
- **Thèmes :** quatre thèmes complets (Clair chaud, Sombre chaud, Graphite
|
||||||
|
sombre, Contraste élevé) plus **Auto** (suit la préférence clair/sombre du
|
||||||
|
système), sélectionnables dans Réglages → Général. Chaque panneau et chaque
|
||||||
|
table AG-Grid suit le thème.
|
||||||
|
- **Bilingue :** interface complète **anglais / français**, avec un choix de
|
||||||
|
drapeau au premier lancement et un sélecteur dans Réglages → Général.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **Coffre à secrets :** chiffrement optionnel par phrase de passe des mots de
|
||||||
|
passe stockés (AES-GCM + PBKDF2). Les valeurs chiffrées sont portables ; une
|
||||||
|
seule invite de déverrouillage au lancement les déchiffre pour la session.
|
||||||
|
|
||||||
|
## Intégrations (sortantes)
|
||||||
|
|
||||||
|
- **Émetteurs UDP :** pousser la fréquence actuelle vers **PstRotator**, les
|
||||||
|
infos radio au format **N1MM `RadioInfo`**, ou un **enregistrement ADIF à
|
||||||
|
chaque QSO logué** — pour que les outils externes (contrôle de rotor,
|
||||||
|
applications numériques, autres logiciels de log) restent synchronisés.
|
||||||
|
|
||||||
|
## Divers
|
||||||
|
|
||||||
|
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||||
|
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||||
|
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||||
|
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
|
||||||
|
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||||
|
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||||
|
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||||
|
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||||
|
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||||
|
désactivable dans les Préférences).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concepteur de cartes QSL
|
||||||
|
|
||||||
|
Outils → *Concepteur de cartes QSL…* transforme quelques photos en une carte
|
||||||
|
eQSL soignée :
|
||||||
|
|
||||||
|
1. Choisissez 1 à 6 photos (jpeg/png). OpsLog les analyse hors ligne (grille de
|
||||||
|
détail/luminance) et propose **3 designs** — indicatif dans la zone la plus
|
||||||
|
calme de la meilleure photo, nom de l'opérateur, zones CQ/ITU + ligne locator,
|
||||||
|
drapeau du pays, les autres photos en inserts encadrés, et un encart de
|
||||||
|
confirmation par QSO.
|
||||||
|
2. Choisissez une proposition et affinez-la : cliquez un élément pour le
|
||||||
|
sélectionner, glissez pour déplacer, changez la police / le préréglage de
|
||||||
|
style (gel or, gel argent, contour blanc classique, script, plat) et les
|
||||||
|
réglages par préréglage dans le panneau de droite.
|
||||||
|
3. Enregistrez le modèle (les photos sont copiées dans
|
||||||
|
`data/qsl/templates/<id>/`, les originaux peuvent donc être déplacés). Un
|
||||||
|
modèle peut être le défaut par profil.
|
||||||
|
|
||||||
|
Envoi : clic droit sur un QSO → *Envoyer l'eQSL par e-mail*. La carte est rendue
|
||||||
|
avec les données de ce QSO, matricée en JPEG ≤ 800 Ko, archivée dans
|
||||||
|
`data/qsl/outbox/` et envoyée via le compte SMTP configuré à l'adresse trouvée
|
||||||
|
par la recherche QRZ/HamQTH. En cas de succès le QSO est marqué `EQSL_SENT=Y`
|
||||||
|
(ADIF). Les modèles de sujet/corps de l'e-mail sont dans le concepteur (variables
|
||||||
|
`{CALL} {DATE} {BAND} {MODE} {MYCALL}`).
|
||||||
|
|
||||||
|
Polices : Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura
|
||||||
|
(toutes OFL, embarquées — licences dans `internal/qslcard/assets/fonts/`) ;
|
||||||
|
Cooper Black est proposée si MS Office l'a installée. Drapeaux : flag-icons
|
||||||
|
(MIT), embarqués pour les entités DXCC couramment contactées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Données & stockage
|
||||||
|
|
||||||
|
- La **config** (réglages, profils, radios/antennes, nœuds de cluster, cache de
|
||||||
|
recherche, listes de diplômes, modèles QSL) réside toujours dans le fichier
|
||||||
|
SQLite local sous `data/` — instantané même quand le journal est sur un MySQL
|
||||||
|
lointain.
|
||||||
|
- Le **journal** (QSO) réside là où pointe le profil actif : le fichier SQLite
|
||||||
|
local ou une base **MySQL** partagée par profil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*An English version of this document is available in [README.md](README.md).*
|
||||||
@@ -1,158 +1,77 @@
|
|||||||
# OpsLog
|
# Transceiver Control Interface
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
## Introduction
|
||||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
|
||||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
|
||||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
|
||||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
|
||||||
|
|
||||||
Developed by **F4BPO**.
|
TCI (Transceiver Control Interface) is a network interface for control, data transfer and
|
||||||
|
synchronization between transceiver/receiver, contest loggers, digital mode software, skimmers
|
||||||
|
and other software, as well as external power amplifiers, bandpass filter units, antenna switches,
|
||||||
|
radio controllers and other devices.
|
||||||
|
|
||||||
---
|
TCI was created as a modern alternative to the outdated COM port and audio cable
|
||||||
|
interfaces, it uses a full duplex web socket protocol that runs on top of a TCP connection and
|
||||||
|
serves for server-client communications, providing cross-platform connectivity. Transceiver works
|
||||||
|
as a server, all other software and devices as clients. The server and clients can be inside the
|
||||||
|
same computer (program-server, hardware log, etc.-clients) and/or in separate physical devices
|
||||||
|
connected through the local network (classical transceiver, power amplifier, antenna switch, FFT
|
||||||
|
unit, etc.).
|
||||||
|
|
||||||
## Building / developing
|
The TCI interface contains basic transceiver control commands (analog of CAT system),
|
||||||
|
receives CW macros from clients and broadcasts them, outputs transceiver IQ stream to clients,
|
||||||
|
receives spots from skimmers and Internet clusters, receives/outputs audio signal to work in
|
||||||
|
digital modes.
|
||||||
|
|
||||||
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
The TCI uses an extensible architecture and can be supplemented with new functions and
|
||||||
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
commands, while keeping the old ones operational. Thus, the TCI interface can be extended and
|
||||||
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
supplemented to meet the specific needs of any software manufacturer and/or device
|
||||||
`wails generate module`.
|
manufacturer (receivers, transceivers, power amplifiers, switches, etc.). The presence of a device
|
||||||
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
identifier allows the manufacturers of transceivers and receivers to switch to the TCI interface
|
||||||
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
while maintaining the device model designation. The extensibility of the TCI interface allows you
|
||||||
and publishes it to Gitea + GitHub releases.
|
to create an individual set of commands and functions for each device model, while maintaining
|
||||||
|
the basic command set inherent to all transceivers.
|
||||||
|
|
||||||
---
|
Our company advocates universal unification of data exchange between devices and
|
||||||
|
software by creating the TCI interface for this purpose. Modern transceivers and software must
|
||||||
|
communicate using one protocol - the TCI protocol.
|
||||||
|
|
||||||
## Logging
|
## Interface description
|
||||||
|
|
||||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
Any command represents an ASCII string that contains a command name and a list of
|
||||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
arguments corresponding to this command. There are reserved characters that cannot be
|
||||||
**flag** is shown large next to the RST fields.
|
included in the command name and command arguments.
|
||||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
|
||||||
and the QRZ.com tab.
|
|
||||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
|
||||||
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
|
||||||
date overrides.
|
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
|
||||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
|
||||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
|
||||||
|
|
||||||
## Maps & antenna
|
List of reserved characters: «:», «,», «;».
|
||||||
|
|
||||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
Command structure:
|
||||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
1. Name of the command;
|
||||||
worked-before grid, or the **FlexRadio controls**.
|
2. Separating character between command name and arguments «:»;
|
||||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
3. Separating character between arguments «,»;
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
4. End of the command character «;».
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
|
||||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
|
||||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
|
||||||
compass and the map, so you never lose track of where the antenna points.
|
|
||||||
|
|
||||||
## DX Cluster
|
If a command has no arguments, an end of command symbol is placed after the command
|
||||||
|
name. If the command is invalid, it is ignored. The case of letters does not matter.
|
||||||
|
|
||||||
- Multiple cluster servers with auto-reconnect, a master for commands.
|
The ExpertSDR3 program acts as a server, which can have several client connections at
|
||||||
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
the same time, they will be synchronized with each other by the server. When connecting to the
|
||||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
ExpertSDR3, the client receives the current status of the ExpertSDR3, first sending initialization
|
||||||
pane, with a show/hide toggle.
|
commands, then parameters to set the status, such as frequency, modulation, etc.
|
||||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
|
||||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
|
||||||
|
|
||||||
## CAT control
|
When a parameter change occurs in the ExpertSDR3 (server) program, the server notifies
|
||||||
|
all connected clients, i.e., clients do not need to poll the server constantly, any change of state
|
||||||
|
will be sent in time to all clients. If the client sends a new state, the server will set it to itself, as
|
||||||
|
well as send it to all clients, that is, the server acts as a synchronizer. All clients connected to the
|
||||||
|
server will be automatically synchronized. This way of work allows to minimize network load,
|
||||||
|
reducing traffic.
|
||||||
|
|
||||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
The TCI protocol implements the transmission of receiver IQ stream to clients, which is
|
||||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
necessary for the work of special skimmer software, they automatically find the station and
|
||||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
decode it throughout the band, and it also allows you to record radio signals in the file.
|
||||||
Flex display, click → fill the call).
|
|
||||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
|
||||||
from the frequency.
|
|
||||||
|
|
||||||
### FlexRadio control tab (SmartSDR-style)
|
TCI is also used to transmit audio signals of the receiver to clients and to receive audio
|
||||||
|
signals from clients, i.e., the client can transmit audio signals to ExpertSDR3 for radio
|
||||||
|
transmission. The audio stream exchange is designed to work with digital modes, where encoding
|
||||||
|
and decoding is performed by third-party software, as well as in voice modes, where audio macros
|
||||||
|
can be broadcasted, which is very much in demand in contest loggers.
|
||||||
|
|
||||||
Shown only when the CAT backend is a FlexRadio:
|
When working in contests, it is important to record all on the air operation, for this purpose
|
||||||
|
the audio stream from the line-output is sent to all clients. The resulting audio stream can be
|
||||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
recorded to a file or played back with a PC sound card.
|
||||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
|
||||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
|
||||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
|
||||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
|
||||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
|
||||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
|
||||||
|
|
||||||
## Keyers & audio
|
|
||||||
|
|
||||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
|
||||||
- **Digital Voice Keyer** (DVK) message playback.
|
|
||||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
|
||||||
audio in CW).
|
|
||||||
|
|
||||||
## QSL & awards
|
|
||||||
|
|
||||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
|
||||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
|
||||||
manual reference assignment, live reference detection on call entry, and a
|
|
||||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
|
||||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
|
||||||
and **confirmation download** (which auto-refreshes the award stats).
|
|
||||||
- **QSL Card Designer** (see below).
|
|
||||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
|
||||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
|
||||||
password, or a Microsoft app password.)
|
|
||||||
|
|
||||||
## Multi-operator live status (special events)
|
|
||||||
|
|
||||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
|
||||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
|
||||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
|
||||||
`live_status` table every ~15 s. A small PHP renderer
|
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
|
||||||
own web server reads that table and produces a live page/image you can embed on
|
|
||||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
|
||||||
only writes to the DB — it is not a web server.
|
|
||||||
|
|
||||||
## Other
|
|
||||||
|
|
||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
|
||||||
OpsLog startup, skipping any already running.
|
|
||||||
- **Update check** at startup with a toast (toggleable).
|
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## QSL Card Designer
|
|
||||||
|
|
||||||
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
|
|
||||||
|
|
||||||
1. Pick 1–6 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
|
|
||||||
grid) and proposes **3 designs** — callsign in the calmest zone of the best
|
|
||||||
photo, operator name, CQ/ITU zones + locator line, country flag, the other
|
|
||||||
photos as bordered inserts, and a per-QSO confirmation box.
|
|
||||||
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
|
|
||||||
change font / style preset (gel gold, gel silver, classic white outline,
|
|
||||||
script, flat) and per-preset knobs in the right panel.
|
|
||||||
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
|
|
||||||
originals can move). One template can be the default per profile.
|
|
||||||
|
|
||||||
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
|
|
||||||
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
|
|
||||||
and sent through the configured SMTP account to the address found by the
|
|
||||||
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
|
|
||||||
e-mail subject/body templates live in the designer
|
|
||||||
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
|
|
||||||
|
|
||||||
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
|
||||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
|
||||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
|
||||||
commonly-worked DXCC entities.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data & storage
|
|
||||||
|
|
||||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
|
||||||
award lists, QSL templates) always lives in the local SQLite file under
|
|
||||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
|
||||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
|
||||||
file or a per-profile shared **MySQL** database.
|
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||||
|
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||||
|
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||||
|
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||||
|
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||||
|
// just what happens.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||||
|
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||||
|
// contacts appended AFTER you add the file come in.
|
||||||
|
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||||
|
// the next poll instead of importing a truncated QSO.
|
||||||
|
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||||
|
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||||
|
// auto-log) + automatic external-service upload.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyADIFMonitor = "adifmon.config"
|
||||||
|
|
||||||
|
// ADIFWatchFile is one monitored ADIF file.
|
||||||
|
type ADIFWatchFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||||
|
// initialised": the first scan sets it to the file's current size so existing
|
||||||
|
// history is skipped.
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||||
|
type ADIFMonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Files []ADIFWatchFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const eorTag = "<eor>"
|
||||||
|
|
||||||
|
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||||
|
var cfg ADIFMonitorConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||||
|
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
return a.loadADIFMonitorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||||
|
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||||
|
// was added import); a file already present keeps its current read position.
|
||||||
|
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
old := a.loadADIFMonitorLocked()
|
||||||
|
oldOff := make(map[string]int64, len(old.Files))
|
||||||
|
for _, f := range old.Files {
|
||||||
|
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||||
|
}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||||
|
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||||
|
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||||
|
} else {
|
||||||
|
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||||
|
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose an ADIF file to monitor",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||||
|
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||||
|
func (a *App) adifMonitorLoop() {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanADIFMonitors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||||
|
// persisting advanced offsets.
|
||||||
|
func (a *App) scanADIFMonitors() {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cfg := a.loadADIFMonitorLocked()
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// path → new offset, for the files we advanced this pass.
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
f := &cfg.Files[i]
|
||||||
|
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not present (yet) — try again next tick
|
||||||
|
}
|
||||||
|
size := fi.Size()
|
||||||
|
if f.Offset < 0 {
|
||||||
|
// First sight of this file → skip whatever history it already holds.
|
||||||
|
advanced[f.Path] = size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size < f.Offset {
|
||||||
|
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||||
|
}
|
||||||
|
if size == f.Offset {
|
||||||
|
continue // nothing new
|
||||||
|
}
|
||||||
|
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||||
|
if newOff != f.Offset {
|
||||||
|
advanced[f.Path] = newOff
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(advanced) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||||
|
// file list: re-read the stored config and only patch offsets for paths that
|
||||||
|
// still exist there.
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cur := a.loadADIFMonitorLocked()
|
||||||
|
for i := range cur.Files {
|
||||||
|
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||||
|
cur.Files[i].Offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cur)
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||||
|
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||||
|
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||||
|
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, to-from)
|
||||||
|
nRead, err := io.ReadFull(fh, buf)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf = buf[:nRead]
|
||||||
|
|
||||||
|
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||||
|
// waits for the next poll.
|
||||||
|
lower := bytes.ToLower(buf)
|
||||||
|
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||||
|
if last < 0 {
|
||||||
|
return from, 0 // no complete record yet
|
||||||
|
}
|
||||||
|
end := last + len(eorTag)
|
||||||
|
chunk := buf[:end]
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, rec := range splitADIFRecords(chunk) {
|
||||||
|
if strings.TrimSpace(rec) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||||
|
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||||
|
}
|
||||||
|
return from + int64(end), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||||
|
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||||
|
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||||
|
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||||
|
func splitADIFRecords(b []byte) []string {
|
||||||
|
lower := bytes.ToLower(b)
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := start + rel + len(eorTag)
|
||||||
|
out = append(out, string(b[start:end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||||
|
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||||
|
// only by the frontend, and only while the entry mode is CW.
|
||||||
|
//
|
||||||
|
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||||
|
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||||
|
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||||
|
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||||
|
// back to auto-search otherwise.
|
||||||
|
|
||||||
|
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||||
|
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||||
|
func (a *App) cwTargetPitch() int {
|
||||||
|
if a.cwPitchHz > 0 {
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
|
if a.cat != nil {
|
||||||
|
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||||
|
// Only trust the radio's pitch when it's actually in CW.
|
||||||
|
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||||
|
if st.CWPitch > 0 {
|
||||||
|
return st.CWPitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||||
|
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||||
|
// call repeatedly; a second call is a no-op while already running.
|
||||||
|
func (a *App) StartCWDecoder() error {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
if a.cwStop != nil {
|
||||||
|
return nil // already running
|
||||||
|
}
|
||||||
|
dev := ""
|
||||||
|
if a.settings != nil {
|
||||||
|
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||||
|
}
|
||||||
|
if dev == "" {
|
||||||
|
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := cwdecode.New(audio.SampleRate,
|
||||||
|
func(text string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(st cwdecode.Status) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
a.cwDecoder = dec
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
a.cwStop = stop
|
||||||
|
go func() {
|
||||||
|
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||||
|
applog.Printf("cw: capture failed: %v", err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
if a.cwStop == stop {
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
}
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
}()
|
||||||
|
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||||
|
go a.cwFollowPitch(stop, dec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||||
|
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCWDecoder halts the CW decoder if running.
|
||||||
|
func (a *App) StopCWDecoder() {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
stop := a.cwStop
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||||
|
func (a *App) CWDecoderRunning() bool {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||||
|
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||||
|
func (a *App) SetCWDecoderPitch(hz int) {
|
||||||
|
if hz < 0 {
|
||||||
|
hz = 0
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
a.cwPitchHz = hz
|
||||||
|
dec := a.cwDecoder
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if dec != nil {
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||||
|
func (a *App) GetCWDecoderPitch() int {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
+261
-19
@@ -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/.
|
||||||
@@ -122,12 +140,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLListTemplates returns all saved templates (defaults first).
|
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
|
||||||
|
// shared/global ones), defaults first — so the default is per-profile, not a
|
||||||
|
// single global list shared by every profile.
|
||||||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||||
if a.qslTemplates == nil {
|
if a.qslTemplates == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
recs, err := a.qslTemplates.List(a.ctx)
|
var recs []qslcard.Record
|
||||||
|
var err error
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
|
||||||
|
} else {
|
||||||
|
recs, err = a.qslTemplates.List(a.ctx)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -429,28 +455,53 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
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).
|
||||||
@@ -459,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
|
||||||
}
|
}
|
||||||
@@ -470,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,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
|
||||||
@@ -627,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,
|
||||||
@@ -643,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
|
||||||
}
|
}
|
||||||
@@ -681,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtClublogPassword: true,
|
keyExtClublogPassword: true,
|
||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtEQSLPassword: 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,86 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTCITXSource tells the radio where to take its transmit audio from,
|
||||||
|
// following the "To radio" device.
|
||||||
|
//
|
||||||
|
// This is the setting an operator would otherwise have to find in ExpertSDR3
|
||||||
|
// and set again for every mode, because it is remembered per mode there. It is
|
||||||
|
// the third argument of TRX, so OpsLog can simply say it each time it keys —
|
||||||
|
// and a radio told nothing keeps the operator's microphone, which is what
|
||||||
|
// every PTT that is not a voice message should do.
|
||||||
|
func (a *App) applyTCITXSource() {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
src := ""
|
||||||
|
if cfg.ToRadio == audio.NetworkDeviceID {
|
||||||
|
src = "tci"
|
||||||
|
}
|
||||||
|
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
if s, ok := t.(interface{ SetTXAudioSource(string) }); ok {
|
||||||
|
s.SetTXAudioSource(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
a.applyTCITXSource()
|
||||||
|
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,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||||
|
// CORRECTED. These tests pin the three rules that make that safe:
|
||||||
|
// - a new catalog award reaches an operator who already has awards stored;
|
||||||
|
// - a fixed one (higher Version) replaces the stored copy;
|
||||||
|
// - unless the operator has edited it, in which case their work wins.
|
||||||
|
|
||||||
|
func catalogDef(t *testing.T, code string) award.Def {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range award.Defaults() {
|
||||||
|
if d.Code == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s is not in the catalog", code)
|
||||||
|
return award.Def{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||||
|
for _, d := range defs {
|
||||||
|
if d.Code == code {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return award.Def{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||||
|
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||||
|
got, updated, changed := mergeCatalog(stored)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||||
|
}
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "FFMA"); !ok {
|
||||||
|
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||||
|
t.Error("the operator's own award was dropped by the merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||||
|
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "wrong"
|
||||||
|
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||||
|
|
||||||
|
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||||
|
if !changed || len(updated) == 0 {
|
||||||
|
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||||
|
}
|
||||||
|
d, _ := findDef(got, "FFMA")
|
||||||
|
if d.Field != "grid4" {
|
||||||
|
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||||
|
}
|
||||||
|
if d.Valid {
|
||||||
|
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "mine"
|
||||||
|
old.UserEdited = true
|
||||||
|
|
||||||
|
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||||
|
}
|
||||||
|
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||||
|
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||||
|
prev := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||||
|
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||||
|
}
|
||||||
|
next := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||||
|
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||||
|
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||||
|
}
|
||||||
|
markUserEdited(next, prev)
|
||||||
|
|
||||||
|
if next[0].UserEdited {
|
||||||
|
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||||
|
}
|
||||||
|
if !next[1].UserEdited {
|
||||||
|
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||||
|
}
|
||||||
|
if next[2].UserEdited {
|
||||||
|
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||||
|
}
|
||||||
|
// A save must not pretend to be a new shipped revision.
|
||||||
|
if next[0].Version != 2 || next[1].Version != 2 {
|
||||||
|
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||||
|
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||||
|
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||||
|
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||||
|
// logged, not counted in any award slot, and exports without a BAND field.
|
||||||
|
//
|
||||||
|
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||||
|
// missing entirely.
|
||||||
|
func TestBandPlansAgree(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{137000, "2190m"},
|
||||||
|
{475000, "630m"},
|
||||||
|
{1840000, "160m"},
|
||||||
|
{3650000, "80m"},
|
||||||
|
{7100000, "40m"},
|
||||||
|
{10120000, "30m"},
|
||||||
|
{14200000, "20m"},
|
||||||
|
{18100000, "17m"},
|
||||||
|
{21200000, "15m"},
|
||||||
|
{24940000, "12m"},
|
||||||
|
{28400000, "10m"},
|
||||||
|
{50150000, "6m"},
|
||||||
|
{70200000, "4m"},
|
||||||
|
{144300000, "2m"},
|
||||||
|
{223500000, "1.25m"},
|
||||||
|
{432000000, "70cm"},
|
||||||
|
{1296000000, "23cm"},
|
||||||
|
// The microwave bands that were missing. 3 cm is the one an operator
|
||||||
|
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||||
|
{2320000000, "13cm"},
|
||||||
|
{3400000000, "9cm"},
|
||||||
|
{5760000000, "6cm"},
|
||||||
|
{10368000000, "3cm"},
|
||||||
|
{24048000000, "1.25cm"},
|
||||||
|
{47088000000, "6mm"},
|
||||||
|
{76032000000, "4mm"},
|
||||||
|
{122250000000, "2.5mm"},
|
||||||
|
{134928000000, "2mm"},
|
||||||
|
{241920000000, "1mm"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||||
|
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
if got := bandForHz(c.hz); got != c.want {
|
||||||
|
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||||
|
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||||
|
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||||
|
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||||
|
if got := cat.BandFromHz(hz); got != "" {
|
||||||
|
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||||
|
}
|
||||||
|
if got := bandForHz(hz); got != "" {
|
||||||
|
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||||
|
//
|
||||||
|
// These two lists live in different packages — a TypeScript field table on one
|
||||||
|
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||||
|
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||||
|
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||||
|
// could not write and the operator got "field … is not bulk-editable" only after
|
||||||
|
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||||
|
//
|
||||||
|
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||||
|
// field added to the UI alone fails here immediately.
|
||||||
|
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("frontend source not available: %v", err)
|
||||||
|
}
|
||||||
|
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||||
|
if len(ids) < 10 {
|
||||||
|
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||||
|
}
|
||||||
|
for _, m := range ids {
|
||||||
|
id := m[1]
|
||||||
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
|
// band together) and the extras live in extras_json.
|
||||||
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
col, ok := bulkFieldColumns[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||||
|
// written into extras_json, not a column, so the column whitelist does not
|
||||||
|
// apply to them.
|
||||||
|
if strings.HasPrefix(col, "extras.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||||
|
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same contract for the filter builder: every field it offers must be one the
|
||||||
|
// query layer will accept.
|
||||||
|
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("frontend source not available: %v", err)
|
||||||
|
}
|
||||||
|
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||||
|
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||||
|
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||||
|
if len(ids) < 10 {
|
||||||
|
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||||
|
}
|
||||||
|
for _, m := range ids {
|
||||||
|
col := m[1]
|
||||||
|
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||||
|
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a rigctl client is told when it asks "what frequency is the radio on".
|
||||||
|
//
|
||||||
|
// Hamlib's "f" means the VFO IN USE — where the operator is listening. RigState
|
||||||
|
// follows ADIF, where FreqHz is where we TRANSMIT, and the two are the same
|
||||||
|
// number until split is engaged. They were handed over unchanged, so with split
|
||||||
|
// on every client asking for the dial was given VFO B.
|
||||||
|
//
|
||||||
|
// Reported from a station running an IC-7850 over USB: turning VFO B moved
|
||||||
|
// VFO A. OpsLog was writing nothing to that radio — a client read the dial, was
|
||||||
|
// handed the transmit VFO, and wrote it back as the dial.
|
||||||
|
func TestShareRXFreqAnswersTheListeningVFO(t *testing.T) {
|
||||||
|
const rx, tx = 14195000, 14200500
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
st cat.RigState
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simplex — the one frequency there is",
|
||||||
|
st: cat.RigState{FreqHz: rx},
|
||||||
|
want: rx,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "split — the RX VFO, not the TX one",
|
||||||
|
st: cat.RigState{Split: true, FreqHz: tx, RxFreqHz: rx},
|
||||||
|
want: rx,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A backend that reports split without ever filling RxFreqHz would
|
||||||
|
// otherwise be answered with 0, and a client told the radio is on
|
||||||
|
// 0 Hz does something worse than nothing with it.
|
||||||
|
name: "split claimed but no RX frequency known",
|
||||||
|
st: cat.RigState{Split: true, FreqHz: tx},
|
||||||
|
want: tx,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := shareRXFreq(c.st); got != c.want {
|
||||||
|
t.Errorf("shareRXFreq(%+v) = %d, want %d", c.st, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the pair, stated together: the two questions must not return the same
|
||||||
|
// answer while split is on, or the distinction has been lost somewhere.
|
||||||
|
func TestShareSplitReportsTheOtherVFO(t *testing.T) {
|
||||||
|
st := cat.RigState{Split: true, FreqHz: 14200500, RxFreqHz: 14195000}
|
||||||
|
rxAnswer := shareRXFreq(st)
|
||||||
|
txAnswer := st.FreqHz // what Split() hands back for "i" / get_split_freq
|
||||||
|
if rxAnswer == txAnswer {
|
||||||
|
t.Fatalf("get_freq and get_split_freq both answered %d — a client cannot tell the VFOs apart", rxAnswer)
|
||||||
|
}
|
||||||
|
if rxAnswer != st.RxFreqHz || txAnswer != st.FreqHz {
|
||||||
|
t.Errorf("got rx=%d tx=%d, want rx=%d tx=%d", rxAnswer, txAnswer, st.RxFreqHz, st.FreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed changelog.json
|
||||||
|
var changelogJSON []byte
|
||||||
|
|
||||||
|
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||||
|
// shown once on the first launch after an update (the "What's new" dialog),
|
||||||
|
// derived from the release's commit history.
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
EN []string `json:"en"`
|
||||||
|
FR []string `json:"fr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyChangelogSeen = "changelog.last_seen_version"
|
||||||
|
|
||||||
|
func loadChangelog() []ChangelogEntry {
|
||||||
|
var out []ChangelogEntry
|
||||||
|
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||||
|
applog.Printf("changelog: parse failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangelog returns every changelog entry up to the running version (newest
|
||||||
|
// first), without touching the "seen" marker — for a "What's new" button that
|
||||||
|
// reopens the notes on demand.
|
||||||
|
func (a *App) GetChangelog() []ChangelogEntry {
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||||
|
// notes for every version newer than the last one they ran, up to the current
|
||||||
|
// build — and records the current version as seen so it pops exactly once per
|
||||||
|
// update. Empty when there's nothing new.
|
||||||
|
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||||
|
cur := appVersion
|
||||||
|
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||||
|
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(cur, e.Version) {
|
||||||
|
continue // don't preview notes for a version newer than we run
|
||||||
|
}
|
||||||
|
if lastSeen == "" {
|
||||||
|
// First run of the feature (fresh install, or upgrade from a build that
|
||||||
|
// predates it): show only the CURRENT version's notes, once.
|
||||||
|
if e.Version == cur {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(lastSeen, e.Version) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+1882
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
|
||||||
|
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
|
||||||
|
// operators on the same shared log (e.g. a special-event call) can talk. No
|
||||||
|
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
|
||||||
|
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatPollInterval = 3 * time.Second
|
||||||
|
chatPresenceEvery = 20 * time.Second
|
||||||
|
chatRetentionDays = 7
|
||||||
|
chatHistoryDefault = 80
|
||||||
|
chatPresenceStaleSecs = 120 // a presence row older than this = offline
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatMessage is one chat line.
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt string `json:"created_at"` // ISO UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPresence is one operator currently online (recent heartbeat).
|
||||||
|
type ChatPresence struct {
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
AgoSecs int `json:"ago_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatActive reports whether chat can run (shared MySQL logbook).
|
||||||
|
func (a *App) chatActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
|
||||||
|
func (a *App) ChatAvailable() bool { return a.chatActive() }
|
||||||
|
|
||||||
|
func (a *App) ensureChatTables() error {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_messages ("+
|
||||||
|
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
|
||||||
|
"operator VARCHAR(32), station VARCHAR(32), "+
|
||||||
|
"message TEXT, created_at DATETIME)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_presence ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatMessage posts a message to the shared chat and returns the stored row
|
||||||
|
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
|
||||||
|
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ChatMessage{}, nil
|
||||||
|
}
|
||||||
|
if len(text) > 1000 {
|
||||||
|
text = text[:1000]
|
||||||
|
}
|
||||||
|
if !a.chatActive() {
|
||||||
|
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
res, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
|
||||||
|
op, station, text)
|
||||||
|
if err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChatHistory returns the most recent messages (oldest first) for the panel.
|
||||||
|
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = chatHistoryDefault
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
// Reverse to chronological order (we queried newest-first to honour LIMIT).
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineOperators lists operators with a recent presence heartbeat.
|
||||||
|
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
|
||||||
|
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
|
||||||
|
chatPresenceStaleSecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatPresence
|
||||||
|
for rows.Next() {
|
||||||
|
var p ChatPresence
|
||||||
|
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatLoop polls for new messages and heartbeats presence while on a shared
|
||||||
|
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
|
||||||
|
func (a *App) chatLoop() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
var lastID int64 = -1 // -1 = not yet baselined
|
||||||
|
var lastDB *sql.DB // logbook the baseline belongs to
|
||||||
|
lastPresence := time.Time{}
|
||||||
|
lastPurge := time.Time{}
|
||||||
|
t := time.NewTicker(chatPollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if !a.chatActive() {
|
||||||
|
lastID = -1 // re-baseline if the backend changes
|
||||||
|
lastDB = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Profile switch swaps the logbook under us: re-baseline against the new
|
||||||
|
// DB so we don't query it with the previous log's id cursor.
|
||||||
|
if a.logDb != lastDB {
|
||||||
|
lastID = -1
|
||||||
|
lastDB = a.logDb
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
// Presence heartbeat.
|
||||||
|
if now.Sub(lastPresence) >= chatPresenceEvery {
|
||||||
|
if op, station := a.liveStatusOperator(); op != "" {
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station)
|
||||||
|
}
|
||||||
|
lastPresence = now
|
||||||
|
}
|
||||||
|
// Baseline on first run so existing history isn't replayed as "new"
|
||||||
|
// (the panel loads it via GetChatHistory).
|
||||||
|
if lastID < 0 {
|
||||||
|
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
|
||||||
|
_ = row.Scan(&lastID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Emit new messages.
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.ID > lastID {
|
||||||
|
lastID = m.ID
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "chat:message", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
// Purge old messages occasionally (hourly).
|
||||||
|
if now.Sub(lastPurge) >= time.Hour {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("chat: purge failed: %v", err)
|
||||||
|
}
|
||||||
|
lastPurge = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: reads the FIPS county CSV and emits
|
||||||
|
// internal/awardref/uscounties_gen.go. Not part of the build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dxccForState maps the two US "states" that are separate DXCC entities.
|
||||||
|
func dxccForState(st string) int {
|
||||||
|
switch st {
|
||||||
|
case "AK":
|
||||||
|
return 6
|
||||||
|
case "HI":
|
||||||
|
return 110
|
||||||
|
default:
|
||||||
|
return 291
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// territories we exclude entirely — plus DC, which USA-CA does not count as a
|
||||||
|
// county.
|
||||||
|
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
|
||||||
|
|
||||||
|
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
|
||||||
|
// count as counties: the independent cities of Virginia and Carson City (NV).
|
||||||
|
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
|
||||||
|
// one of these counts toward a bordering county under the award rules.
|
||||||
|
func excludedUSACA(state, name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
switch state {
|
||||||
|
case "VA":
|
||||||
|
return strings.HasSuffix(n, " city")
|
||||||
|
case "NV":
|
||||||
|
return n == "carson city"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
type row struct{ code, name string; dxcc int }
|
||||||
|
var rows []row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
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)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(parts[1])
|
||||||
|
st := strings.TrimSpace(parts[2])
|
||||||
|
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if excludedUSACA(st, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// State header rows have an all-caps state NAME and state code "NA"
|
||||||
|
// (already skipped). County rows have a 2-letter state code.
|
||||||
|
code := award.USCountyKey(st, name)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
|
||||||
|
b.WriteString("package awardref\n\n")
|
||||||
|
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
|
||||||
|
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
|
||||||
|
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
|
||||||
|
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
|
||||||
|
}
|
||||||
|
b.WriteString("\t}\n}\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// Command icomnettest is an iteration probe for the Icom IP remote protocol —
|
||||||
|
// the LAN server built into the IC-7610 that the Icom "Remote Utility" (and
|
||||||
|
// wfview) talk to. OpsLog reimplements this directly so it can BE both the
|
||||||
|
// Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the
|
||||||
|
// virtual-COM + RS-BA1 chain entirely.
|
||||||
|
//
|
||||||
|
// This probe drives TWO streams and hex-dumps everything:
|
||||||
|
// Control (UDP 50001): handshake → login → token [VERIFIED on the real rig]
|
||||||
|
// CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq
|
||||||
|
// (FE FE 98 E0 03 FD) → print the rig's reply.
|
||||||
|
// Framing (passcode table, packet offsets, CI-V data_packet, openclose) is
|
||||||
|
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||||
|
// against real Remote-Utility captures (build/bin/civ*.pcapng). No GPLv3 code.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
|
||||||
|
//
|
||||||
|
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var le = binary.LittleEndian
|
||||||
|
var be = binary.BigEndian
|
||||||
|
|
||||||
|
// passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e).
|
||||||
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||||
|
var passcodeSeq = [256]byte{
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||||
|
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||||
|
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||||
|
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||||
|
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||||
|
}
|
||||||
|
|
||||||
|
func passcode(s string) []byte {
|
||||||
|
out := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s) && i < 16; i++ {
|
||||||
|
p := int(s[i]) + i
|
||||||
|
if p > 126 {
|
||||||
|
p = 32 + p%127
|
||||||
|
}
|
||||||
|
out = append(out, passcodeSeq[p])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- packet builders (offsets verified vs wfview structs + real captures) ---
|
||||||
|
|
||||||
|
func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
le.PutUint32(b[0:], 0x10)
|
||||||
|
le.PutUint16(b[4:], typ)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||||
|
b := make([]byte, 0x80)
|
||||||
|
le.PutUint32(b[0:], 0x80)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x80-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x00 // requesttype = login
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
copy(b[0x40:0x50], passcode(user))
|
||||||
|
copy(b[0x50:0x60], passcode(pass))
|
||||||
|
nm := name
|
||||||
|
if len(nm) > 16 {
|
||||||
|
nm = nm[:16]
|
||||||
|
}
|
||||||
|
copy(b[0x60:0x70], []byte(nm))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
le.PutUint32(b[0:], 0x40)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x02 // requesttype = token
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConnInfo — 144-byte sendRequestStream on the CONTROL stream. Tells the
|
||||||
|
// rig to route the CI-V/audio streams to the authenticated session and which
|
||||||
|
// local ports we use. Values verified byte-for-byte vs a real Remote-Utility
|
||||||
|
// capture (civ4): requesttype=0x03, commoncap=0x8010, the rig's MAC echoed,
|
||||||
|
// name "IC-7610", scrambled username, rxenable=0 (audio off — CI-V only),
|
||||||
|
// rxcodec 0x10 / txcodec 0x04, rxsample 16000 / txsample 8000 (BE), civport /
|
||||||
|
// audioport (BE), txbuffer 100.
|
||||||
|
func buildConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
||||||
|
b := make([]byte, 0x90)
|
||||||
|
le.PutUint32(b[0:], 0x90)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x90-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
le.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||||
|
copy(b[0x2a:0x30], rigMAC) // macaddress (the rig's, echoed back)
|
||||||
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
|
copy(b[0x60:0x70], passcode(user))
|
||||||
|
b[0x70] = 0x00 // rxenable (0 = audio off)
|
||||||
|
b[0x71] = 0x00 // txenable
|
||||||
|
b[0x72] = 0x10 // rxcodec
|
||||||
|
b[0x73] = 0x04 // txcodec
|
||||||
|
be.PutUint32(b[0x74:], 16000) // rxsample
|
||||||
|
be.PutUint32(b[0x78:], 8000) // txsample
|
||||||
|
be.PutUint32(b[0x7c:], uint32(civPort))
|
||||||
|
be.PutUint32(b[0x80:], uint32(audioPort))
|
||||||
|
be.PutUint32(b[0x84:], 100) // txbuffer
|
||||||
|
b[0x88] = 0x00 // convert
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOpenClose — 22-byte start/stop for the CI-V stream. magic 0x04=open,
|
||||||
|
// 0x00=close. data=0x01c0 (@0x10), civSeq (BE @0x13), magic (@0x15).
|
||||||
|
func buildOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||||
|
b := make([]byte, 0x16)
|
||||||
|
le.PutUint32(b[0:], 0x16)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
le.PutUint16(b[0x10:], 0x01c0)
|
||||||
|
be.PutUint16(b[0x13:], civSeq)
|
||||||
|
b[0x15] = magic
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCivData — wraps raw CI-V bytes: 21-byte header (reply 0xc1 @0x10,
|
||||||
|
// datalen LE @0x11, civSeq BE @0x13) + CI-V frame @0x15.
|
||||||
|
func buildCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||||
|
n := 0x15 + len(civ)
|
||||||
|
b := make([]byte, n)
|
||||||
|
le.PutUint32(b[0:], uint32(n))
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
b[0x10] = 0xc1
|
||||||
|
le.PutUint16(b[0x11:], uint16(len(civ)))
|
||||||
|
be.PutUint16(b[0x13:], civSeq)
|
||||||
|
copy(b[0x15:], civ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func header(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
|
||||||
|
if len(b) < 16 {
|
||||||
|
return 0, 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func localID(conn net.Conn) uint32 {
|
||||||
|
a := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
return uint32(a.IP.To4()[0])<<24 | uint32(a.IP.To4()[1])<<16 | uint32(uint16(a.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
func recv(conn net.Conn, ms int, buf []byte) ([]byte, bool) {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return append([]byte(nil), buf[:n]...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dump(tag string, p []byte) { fmt.Printf("%s (%d)\n%s\n", tag, len(p), hex.Dump(p)) }
|
||||||
|
|
||||||
|
// pingReply mirrors a ping, swaps ids, sets the reply flag at offset 16.
|
||||||
|
func pingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||||
|
r := append([]byte(nil), pkt...)
|
||||||
|
if len(r) >= 17 {
|
||||||
|
le.PutUint32(r[8:], myID)
|
||||||
|
le.PutUint32(r[12:], remoteID)
|
||||||
|
r[16] = 0x01
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||||
|
// Returns the rig's remote id. Replies to any pings meanwhile.
|
||||||
|
func handshake(conn net.Conn, myID uint32, label string) (uint32, bool) {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
conn.Write(ctrlPacket(0x03, 0, myID, 0)) // areYouThere
|
||||||
|
fmt.Printf("[%s] TX areYouThere\n", label)
|
||||||
|
var remoteID uint32
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
lastTry := time.Now()
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
p, ok := recv(conn, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||||
|
conn.Write(ctrlPacket(0x03, 0, myID, 0))
|
||||||
|
lastTry = time.Now()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, typ, _, sentid, _, ok := header(p)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case 0x04: // iAmHere
|
||||||
|
remoteID = sentid
|
||||||
|
fmt.Printf("[%s] iAmHere remoteID=0x%08X → TX areYouReady\n", label, remoteID)
|
||||||
|
conn.Write(ctrlPacket(0x06, 1, myID, remoteID))
|
||||||
|
case 0x06: // iAmReady
|
||||||
|
if remoteID != 0 {
|
||||||
|
fmt.Printf("[%s] iAmReady — link up ✓\n", label)
|
||||||
|
return remoteID, true
|
||||||
|
}
|
||||||
|
case 0x07: // ping
|
||||||
|
conn.Write(pingReply(p, myID, remoteID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteID, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
|
||||||
|
compName := "OpsLog"
|
||||||
|
if len(os.Args) >= 5 {
|
||||||
|
compName = os.Args[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CONTROL STREAM (50001) =====================
|
||||||
|
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial control: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer ctrl.Close()
|
||||||
|
cID := localID(ctrl)
|
||||||
|
fmt.Printf("=== CONTROL 50001 (myID=0x%08X) ===\n", cID)
|
||||||
|
fmt.Printf("scrambled user=% X pass=% X\n\n", passcode(user), passcode(pass))
|
||||||
|
|
||||||
|
cRemote, ok := handshake(ctrl, cID, "ctrl")
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("control handshake failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// login → token
|
||||||
|
var cTracked uint16 = 1
|
||||||
|
var cInner uint16 = 1
|
||||||
|
tokReq := uint16(0x0c77)
|
||||||
|
dump("[ctrl] TX login", buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
ctrl.Write(buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
|
||||||
|
var token uint32
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
for token == 0 && time.Now().Before(deadline) {
|
||||||
|
p, ok := recv(ctrl, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
length, typ, _, _, _, _ := header(p)
|
||||||
|
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||||
|
token = le.Uint32(p[0x1c:])
|
||||||
|
errCode := le.Uint32(p[0x30:])
|
||||||
|
if errCode != 0 || token == 0 {
|
||||||
|
fmt.Printf(">> LOGIN REJECTED err=0x%08X token=0x%08X\n", errCode, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(">> LOGIN OK ✓ token=0x%08X\n", token)
|
||||||
|
ctrl.Write(buildToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
} else if typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == 0 {
|
||||||
|
fmt.Println("no token — login not accepted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send conninfo on the control stream — routes the CI-V stream to this
|
||||||
|
// authenticated session and announces our civ/audio local ports (50002/3).
|
||||||
|
rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet)
|
||||||
|
dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
|
ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
// Let the rig's caps/conninfo replies flow for ~600ms (reply to pings).
|
||||||
|
drainEnd := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(drainEnd) {
|
||||||
|
if p, ok := recv(ctrl, 100, buf); ok {
|
||||||
|
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CI-V STREAM (50002) =====================
|
||||||
|
// Bind our civ socket to LOCAL port 50002 (= the civport announced above),
|
||||||
|
// as the Remote Utility does. Requires the Remote Utility to be CLOSED.
|
||||||
|
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, &net.UDPAddr{IP: net.ParseIP(ip), Port: 50002})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial civ (local :50002 — is the Remote Utility still running?): %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer civ.Close()
|
||||||
|
vID := localID(civ)
|
||||||
|
fmt.Printf("\n=== CI-V 50002 (myID=0x%08X) ===\n", vID)
|
||||||
|
vRemote, ok := handshake(civ, vID, "civ")
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("CI-V handshake failed (may need the conninfo packet on control first)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vTracked uint16 = 1 // outer tracked seq @0x06
|
||||||
|
var vCivSeq uint16 = 1 // inner CI-V seq @0x13 (BE)
|
||||||
|
// openClose(open) starts CI-V data flow.
|
||||||
|
dump("[civ] TX openClose(open)", buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||||
|
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||||
|
vTracked++
|
||||||
|
vCivSeq++
|
||||||
|
|
||||||
|
// Try several read commands, spaced out. Some rigs NG the basic 0x03 read
|
||||||
|
// over the network tunnel; 0x25 / 0x04 and unsolicited transceive frames
|
||||||
|
// (sent when you turn the VFO) still work. The tunnel itself is proven, so
|
||||||
|
// this figures out which read the rig actually answers.
|
||||||
|
sendCiv := func(name string, f []byte) {
|
||||||
|
fmt.Printf("[civ] TX %s\n", name)
|
||||||
|
civ.Write(buildCivData(vTracked, vID, vRemote, vCivSeq, f))
|
||||||
|
vTracked++
|
||||||
|
vCivSeq++
|
||||||
|
}
|
||||||
|
// The rig is in STANDBY (network up, radio off) — it NG's every command
|
||||||
|
// until powered on via CI-V. Send power-on (0x18 0x01, with an FE wake
|
||||||
|
// preamble, as the Remote Utility does), then poll read-freq while it boots.
|
||||||
|
powerOn := make([]byte, 0, 32)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
powerOn = append(powerOn, 0xFE)
|
||||||
|
}
|
||||||
|
powerOn = append(powerOn, 0xFE, 0xFE, 0x98, 0xE0, 0x18, 0x01, 0xFD)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
sendCiv("POWER ON (0x18 01)", powerOn)
|
||||||
|
fmt.Print("\n>>> rig booting (~10-15 s) — polling read-freq until it answers <<<\n\n")
|
||||||
|
readFreq := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||||
|
|
||||||
|
cbuf := make([]byte, 4096)
|
||||||
|
vbuf := make([]byte, 4096)
|
||||||
|
end := time.Now().Add(30 * time.Second)
|
||||||
|
lastIdleC, lastIdleV, lastCmd := time.Now(), time.Now(), time.Now()
|
||||||
|
for time.Now().Before(end) {
|
||||||
|
if p, ok := recv(ctrl, 40, cbuf); ok {
|
||||||
|
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
} else if time.Since(lastIdleC) > 200*time.Millisecond {
|
||||||
|
ctrl.Write(ctrlPacket(0x00, 0, cID, cRemote))
|
||||||
|
lastIdleC = time.Now()
|
||||||
|
}
|
||||||
|
if p, ok := recv(civ, 40, vbuf); ok {
|
||||||
|
_, typ, _, _, _, _ := header(p)
|
||||||
|
if typ == 0x07 {
|
||||||
|
civ.Write(pingReply(p, vID, vRemote))
|
||||||
|
} else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 {
|
||||||
|
f := p[0x15:]
|
||||||
|
if d := decodeCiv(f); d != "" {
|
||||||
|
fmt.Printf(">> CI-V RX: % X %s\n", f, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if time.Since(lastIdleV) > 200*time.Millisecond {
|
||||||
|
civ.Write(ctrlPacket(0x00, 0, vID, vRemote))
|
||||||
|
lastIdleV = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastCmd) > 1000*time.Millisecond {
|
||||||
|
sendCiv("read-freq 0x03", readFreq)
|
||||||
|
lastCmd = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean close.
|
||||||
|
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x00)) // openClose(close)
|
||||||
|
civ.Write(ctrlPacket(0x05, 0, vID, vRemote)) // disconnect
|
||||||
|
ctrl.Write(ctrlPacket(0x05, 0, cID, cRemote))
|
||||||
|
fmt.Println("\nDone. Look for '>> CI-V RX:' and 'FREQUENCY reply'.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCiv describes a received CI-V frame (FE FE <to> <from> <cmd> … FD).
|
||||||
|
// Only frames FROM the rig (from=0x98) are interesting; our own echoed commands
|
||||||
|
// (from=0xE0) return "" so they're not printed.
|
||||||
|
func decodeCiv(f []byte) string {
|
||||||
|
if len(f) < 6 || f[0] != 0xFE || f[1] != 0xFE {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if f[3] != 0x98 { // not from the rig (our echoed command) — skip
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cmd := f[4]
|
||||||
|
body := f[5 : len(f)-1] // between cmd and the trailing FD
|
||||||
|
switch cmd {
|
||||||
|
case 0xFA:
|
||||||
|
return "NG (command rejected)"
|
||||||
|
case 0xFB:
|
||||||
|
return "OK (ack)"
|
||||||
|
case 0x00, 0x03, 0x05: // (transceive) freq / read-freq
|
||||||
|
if len(body) >= 5 {
|
||||||
|
return "FREQ " + decodeFreq(body[:5])
|
||||||
|
}
|
||||||
|
case 0x25: // read/set VFO freq (body = subcmd + 5 BCD)
|
||||||
|
if len(body) >= 6 {
|
||||||
|
return fmt.Sprintf("VFO%d FREQ %s", body[0], decodeFreq(body[1:6]))
|
||||||
|
}
|
||||||
|
case 0x01, 0x04: // (transceive) mode / read-mode
|
||||||
|
if len(body) >= 1 {
|
||||||
|
return fmt.Sprintf("MODE 0x%02X filt 0x%02X", body[0], lastOr(body, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("cmd 0x%02X", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastOr(b []byte, i int) byte {
|
||||||
|
if i < len(b) {
|
||||||
|
return b[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFreq turns Icom little-endian BCD (5 bytes) into a MHz string.
|
||||||
|
func decodeFreq(bcd []byte) string {
|
||||||
|
var hz uint64
|
||||||
|
mul := uint64(1)
|
||||||
|
for _, b := range bcd {
|
||||||
|
hz += uint64(b&0x0f) * mul
|
||||||
|
mul *= 10
|
||||||
|
hz += uint64(b>>4) * mul
|
||||||
|
mul *= 10
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.6f MHz", float64(hz)/1e6)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire
|
||||||
|
// format it expects for the "DXLog.net" tracker. We don't have the spec, so this
|
||||||
|
// sends a series of labelled candidate datagrams to the target and pauses between
|
||||||
|
// each so you can watch PstRotatorAz and note which one it reacts to (the band
|
||||||
|
// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency
|
||||||
|
// shows in the tracker box).
|
||||||
|
//
|
||||||
|
// Setup on the PstRotatorAz side:
|
||||||
|
// - Tracker = DXLog.net
|
||||||
|
// - Note the UDP port it listens on (commonly 12040) and this PC's IP
|
||||||
|
// - In the tracker setup tick a couple of bands so the band filter is visible
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x
|
||||||
|
//
|
||||||
|
// Watch PstRotatorAz as it runs; when one variant makes it react, that's the
|
||||||
|
// format — tell me the variant number and I'll wire it into OpsLog.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type variant struct {
|
||||||
|
name string
|
||||||
|
// build returns the datagram for a given frequency in kHz.
|
||||||
|
build func(khz float64) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Println("usage: psttest <ip:port> [freqKHz] [repeat]")
|
||||||
|
fmt.Println("example: psttest 192.168.1.50:12040 3525.5")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
target := os.Args[1]
|
||||||
|
khz := 3525.5
|
||||||
|
if len(os.Args) >= 3 {
|
||||||
|
if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil {
|
||||||
|
khz = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeat := 1
|
||||||
|
if len(os.Args) >= 4 {
|
||||||
|
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 {
|
||||||
|
repeat = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hz := int64(khz*1000 + 0.5)
|
||||||
|
tensHz := hz / 10 // N1MM/DXLog <Freq> unit
|
||||||
|
|
||||||
|
// Tag <PST><FREQUENCY> is confirmed. PstRotator strips the decimal point and
|
||||||
|
// reads the digits, so pin down the exact unit: watch which one shows the real
|
||||||
|
// frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255.
|
||||||
|
variants := []variant{
|
||||||
|
{"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", hz) }},
|
||||||
|
{"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensHz) }},
|
||||||
|
{"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", int64(k+0.5)) }},
|
||||||
|
{"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%.1f</FREQUENCY></PST>", k) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.Dial("udp", target)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial %s: %v\n", target, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat)
|
||||||
|
for i, v := range variants {
|
||||||
|
msg := v.build(khz)
|
||||||
|
fmt.Printf("── Variant %d: %s\n", i+1, v.name)
|
||||||
|
fmt.Printf(" payload: %s\n", msg)
|
||||||
|
for r := 0; r < repeat; r++ {
|
||||||
|
if _, err := conn.Write([]byte(msg)); err != nil {
|
||||||
|
fmt.Printf(" write error: %v\n", err)
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n")
|
||||||
|
time.Sleep(4 * time.Second)
|
||||||
|
}
|
||||||
|
fmt.Println("Done. Which variant number made PstRotatorAz react?")
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
|
||||||
$DB_NAME = 'opslog'; // database name
|
$DB_NAME = 'opslog'; // database name
|
||||||
$DB_USER = 'opslog';
|
$DB_USER = 'opslog';
|
||||||
$DB_PASS = 'CHANGE_ME';
|
$DB_PASS = 'CHANGE_ME';
|
||||||
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
|
|||||||
// instead of a fatal "table doesn't exist".
|
// instead of a fatal "table doesn't exist".
|
||||||
mysqli_report(MYSQLI_REPORT_OFF);
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||||
if ($mysqli->connect_errno) {
|
if ($mysqli->connect_errno) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
exit('DB error');
|
exit('DB error');
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5675
-595
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { ADIFFields } from '../../wailsjs/go/main/App';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldDef = {
|
type FieldDef = {
|
||||||
name: string; kind: string; category: string;
|
name: string; kind: string; category: string;
|
||||||
@@ -21,6 +22,7 @@ interface Props {
|
|||||||
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
||||||
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||||
export function AdifExtrasEditor({ value, onChange }: Props) {
|
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [dict, setDict] = useState<FieldDef[]>([]);
|
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
|
|
||||||
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
|
{t('adx.introPart1')}<code className="bg-muted px-1 rounded font-mono">APP_*</code>{t('adx.introPart2')}<strong>{t('adx.fullMode')}</strong>{t('adx.introPart3')}
|
||||||
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
|
|
||||||
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add a field */}
|
{/* Add a field */}
|
||||||
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
<Combobox
|
<Combobox
|
||||||
value=""
|
value=""
|
||||||
options={addable}
|
options={addable}
|
||||||
placeholder="Add ADIF field…"
|
placeholder={t('adx.addFieldPh')}
|
||||||
allowFreeText
|
allowFreeText
|
||||||
onChange={addField}
|
onChange={addField}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
||||||
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||||
Show deprecated
|
{t('adx.showDeprecated')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current entries */}
|
{/* Current entries */}
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
||||||
No extra ADIF fields. Use the picker above to add one.
|
{t('adx.noExtra')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -105,11 +105,11 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
<span className="font-mono text-xs font-semibold">{k}</span>
|
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||||
{def && (
|
{def && (
|
||||||
<span className="block text-[10px] text-muted-foreground leading-tight">
|
<span className="block text-[10px] text-muted-foreground leading-tight">
|
||||||
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
|
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
|
||||||
{!def && ''}
|
{!def && ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
|
{!def && <span className="block text-[10px] text-warning leading-tight">{t('adx.nonStandard')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
className="flex-1 h-8 text-xs font-mono"
|
className="flex-1 h-8 text-xs font-mono"
|
||||||
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => remove(k)} title="Remove field"
|
onClick={() => remove(k)} title={t('adx.removeField')}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||||
|
} from '@/../wailsjs/go/main/App';
|
||||||
|
import { alerts } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type Rule = alerts.Rule;
|
||||||
|
|
||||||
|
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||||
|
|
||||||
|
function emptyRule(): Rule {
|
||||||
|
return alerts.Rule.createFrom({
|
||||||
|
id: '', name: 'New alert', enabled: true,
|
||||||
|
calls: [], countries: [], continents: [], bands: [], modes: [],
|
||||||
|
spotter_call: '', spotter_continents: [], spotter_countries: [],
|
||||||
|
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiCheck — a scrollable checkbox list for a set of options with an optional
|
||||||
|
// search box (used for the long country list). Empty selection = ALL.
|
||||||
|
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||||
|
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||||
|
[options, q],
|
||||||
|
);
|
||||||
|
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
{searchable && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||||
|
<Search className="size-3 text-muted-foreground" />
|
||||||
|
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn('overflow-y-auto p-1', height)}>
|
||||||
|
{shown.map((o) => (
|
||||||
|
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
|
||||||
|
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
|
||||||
|
<span className="truncate">{o}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||||
|
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||||
|
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
|
const [draft, setDraft] = useState<Rule | null>(null);
|
||||||
|
// Raw text of the callsigns box, kept separately from draft.calls. The list is
|
||||||
|
// normalised (trim + drop empties) on the way into the rule, so binding the
|
||||||
|
// textarea straight to calls.join('\n') round-trips every keystroke through
|
||||||
|
// that normalisation — a trailing newline or space is stripped before React
|
||||||
|
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
|
||||||
|
// deriving the list from it keeps typing intact.
|
||||||
|
const [callsText, setCallsText] = useState('');
|
||||||
|
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||||
|
const [emailTo, setEmailTo] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||||
|
|
||||||
|
// loadDraft opens a rule in the editor — always through here, so the raw
|
||||||
|
// callsigns text is re-seeded from whatever rule is now being edited.
|
||||||
|
const loadDraft = (r: Rule | null) => {
|
||||||
|
setDraft(r);
|
||||||
|
setCallsText((r?.calls ?? []).join('\n'));
|
||||||
|
};
|
||||||
|
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||||
|
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||||
|
if (!d) return d;
|
||||||
|
const cur = ((d as any)[key] as string[]) ?? [];
|
||||||
|
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
|
||||||
|
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
|
||||||
|
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() {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
|
try {
|
||||||
|
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() {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.id) { loadDraft(null); return; }
|
||||||
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
|
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)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||||
|
{/* Rule list */}
|
||||||
|
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
|
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||||
|
{rules.map((r) => (
|
||||||
|
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||||
|
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||||
|
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||||
|
<span className="truncate flex-1">{r.name}</span>
|
||||||
|
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||||
|
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 border-t border-border/60">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">{t('altm.emailTo')}</Label>
|
||||||
|
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||||
|
onChange={(e) => setEmailTo(e.target.value)}
|
||||||
|
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||||
|
{!draft ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">{t('altm.selectOrCreate')}</div>
|
||||||
|
) : (
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex items-center gap-2 px-2 pt-2">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||||
|
<TabsContent value="def" className="mt-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{t('altm.ruleName')}</Label>
|
||||||
|
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-40 shrink-0">{t('altm.againAfter')}</Label>
|
||||||
|
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||||
|
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> {t('altm.visual')}</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> {t('altm.sound')}</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> {t('altm.email')}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
|
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
|
||||||
|
</label>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||||
|
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||||
|
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||||
|
value={callsText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCallsText(e.target.value);
|
||||||
|
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||||
|
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label className="text-xs">{t('altm.continents')}</Label>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{CONTINENTS.map((c) => (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.bands')}</Label>
|
||||||
|
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.modes')}</Label>
|
||||||
|
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.spotterCall')}</Label>
|
||||||
|
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
|
||||||
|
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||||
|
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{CONTINENTS.map((c) => (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
|
||||||
|
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor actions */}
|
||||||
|
<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 && savedMsg && <span className="text-[11px] text-success flex-1 truncate">{savedMsg}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end px-5 pb-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Flame, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||||
|
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
||||||
|
// of a stripped-down variant. It handles all three amp families:
|
||||||
|
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
||||||
|
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
||||||
|
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
||||||
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||||
|
// can each get their own card.
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
||||||
|
function speMaxW(model?: string): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
return 1300;
|
||||||
|
}
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||||
|
|
||||||
|
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).
|
||||||
|
// 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 peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSPE = !!amp.spe;
|
||||||
|
const isACOM = !!amp.acom;
|
||||||
|
|
||||||
|
if (isSPE) {
|
||||||
|
const spe = amp.spe;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
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'}
|
||||||
|
</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">
|
||||||
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||||
|
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>
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||||
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||||
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{powerLevelLabel(lvl)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||||
|
</span>
|
||||||
|
{spe.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{(spe.warnings || spe.alarms) && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spe.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||||
|
lo={0} hi={speMaxW(spe.model)}
|
||||||
|
display={`${Number(spe.output_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isACOM) {
|
||||||
|
const acom = amp.acom;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
acom.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')}>
|
||||||
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS 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>
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||||
|
</span>
|
||||||
|
{acom.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{acom.err_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{acom.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={Number(acom.max_w) || 800}
|
||||||
|
display={`${Number(acom.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the 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;
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<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">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
{(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'))}>
|
||||||
|
<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="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||||
|
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning 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>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{fault && fault !== 'NONE' && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||||
|
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 dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
{amps.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
|
}
|
||||||
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
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} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,148 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||||
|
export type AGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
|
antennas: AGAntenna[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
|
||||||
|
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
|
||||||
|
// configured for the current band, like the native app. If the mapping ever
|
||||||
|
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
|
||||||
|
// device's actual mask so it can be corrected.
|
||||||
|
const AG_BAND_BITS: Record<string, number> = {
|
||||||
|
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
|
||||||
|
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function bandBit(band?: string): number {
|
||||||
|
if (!band) return 0;
|
||||||
|
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
|
function pretty(name: string): string {
|
||||||
|
const t = name.trim();
|
||||||
|
if (!t) return t;
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
|
// different element instances), so antenna changes silently did nothing.
|
||||||
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
|
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||||
|
status: AGStatus;
|
||||||
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
|
onClose: () => void;
|
||||||
|
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const allAntennas = status.antennas ?? [];
|
||||||
|
|
||||||
|
// Band filter (persisted, default ON): show only antennas whose band mask
|
||||||
|
// includes the current band — matching the native 4O3A app. Antennas with an
|
||||||
|
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
|
||||||
|
// band we can't map, or masks the device didn't send) we fall back to the full
|
||||||
|
// list so the panel is never mysteriously empty.
|
||||||
|
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
|
||||||
|
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
|
||||||
|
const bit = bandBit(band);
|
||||||
|
let list = allAntennas;
|
||||||
|
if (bandFilter && bit !== 0) {
|
||||||
|
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
|
||||||
|
if (filtered.length > 0) list = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={toggleBandFilter}
|
||||||
|
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
|
||||||
|
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
|
||||||
|
<ListFilter className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="text-center py-6 text-xs space-y-2">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||||
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||||
|
) : list.map((a) => {
|
||||||
|
const aActive = status.port_a === a.index;
|
||||||
|
const bActive = status.port_b === a.index;
|
||||||
|
const aTx = aActive && !!status.tx_a;
|
||||||
|
const bTx = bActive && !!status.tx_b;
|
||||||
|
const nameCls = (aTx || bTx)
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
|
||||||
|
: aActive
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
|
||||||
|
: bActive
|
||||||
|
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
return (
|
||||||
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||||
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
|
{pretty(a.name)}
|
||||||
|
</div>
|
||||||
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X, Loader2 } from 'lucide-react';
|
import { X, Loader2 } from 'lucide-react';
|
||||||
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ interface Props {
|
|||||||
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
||||||
// result assigns it to the QSO.
|
// result assigns it to the QSO.
|
||||||
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [results, setResults] = useState<Ref[]>([]);
|
const [results, setResults] = useState<Ref[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -48,14 +50,14 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
|||||||
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||||
<div className="relative" ref={boxRef}>
|
<div className="relative" ref={boxRef}>
|
||||||
{value ? (
|
{value ? (
|
||||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
|
||||||
<span className="font-mono font-semibold">{value}</span>
|
<span className="font-mono font-semibold">{value}</span>
|
||||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
|
<button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
placeholder={`Search ${label}…`}
|
placeholder={t('awrp.searchLabel', { label })}
|
||||||
value={q}
|
value={q}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||||
@@ -63,9 +65,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
|||||||
)}
|
)}
|
||||||
{open && !value && (
|
{open && !value && (
|
||||||
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
||||||
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching…</div>}
|
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
|
||||||
{!busy && results.length === 0 && (
|
{!busy && results.length === 0 && (
|
||||||
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
||||||
)}
|
)}
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,12 +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';
|
||||||
|
|
||||||
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/…)
|
||||||
@@ -36,6 +37,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||||
const [awardCode, setAwardCode] = useState('POTA');
|
const [awardCode, setAwardCode] = useState('POTA');
|
||||||
@@ -52,11 +54,56 @@ 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(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
if (!entriesKey) { setIsNew({}); return; }
|
||||||
.then(([d, m]) => {
|
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),
|
||||||
|
// 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.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setSelectedRef(null);
|
||||||
|
setQ('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [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(() => {
|
||||||
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||||
|
.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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -69,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
|
||||||
@@ -80,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(() => {
|
||||||
@@ -196,25 +249,21 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
{/* Group / Sub from selected ref */}
|
{/* Group / Sub from selected ref */}
|
||||||
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||||
<span className="text-muted-foreground text-[11px]">Group</span>
|
<span className="text-muted-foreground text-[11px]">{t('awrs.group')}</span>
|
||||||
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||||
<span className="text-muted-foreground text-[11px]">Sub</span>
|
<span className="text-muted-foreground text-[11px]">{t('awrs.sub')}</span>
|
||||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected ref chip */}
|
{/* Selected ref chip (nothing shown until one is picked) */}
|
||||||
{selectedRef ? (
|
{selectedRef && (
|
||||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
<button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
|
||||||
← pick a reference
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add — references are always scoped to the contacted DXCC */}
|
{/* Add — references are always scoped to the contacted DXCC */}
|
||||||
@@ -224,10 +273,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
onClick={() => selectedRef && addRef(selectedRef)}
|
onClick={() => selectedRef && addRef(selectedRef)}
|
||||||
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Plus className="size-3" />Add
|
<Plus className="size-3" />{t('awrs.add')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,7 +285,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
{/* Added refs list */}
|
{/* Added refs list */}
|
||||||
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
|
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
|
||||||
) : (
|
) : (
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
@@ -246,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); }}
|
||||||
@@ -261,7 +319,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
{/* Right panel: reference search */}
|
{/* Right panel: reference search */}
|
||||||
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
|
||||||
<span className="text-xs font-semibold">References</span>
|
<span className="text-xs font-semibold">{t('awrs.references')}</span>
|
||||||
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||||
{autoMatch && (
|
{autoMatch && (
|
||||||
<button
|
<button
|
||||||
@@ -270,20 +328,20 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||||
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||||
autoAlreadyAdded
|
autoAlreadyAdded
|
||||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
|
||||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
: 'border-success-border bg-success-muted/60 text-success-muted-foreground hover:bg-success-muted'
|
||||||
}`}
|
}`}
|
||||||
title={`The ${selField.toUpperCase()} field is ${autoMatch.code} — this award counts it automatically`}
|
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||||
>
|
>
|
||||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||||
<span className="text-emerald-700"> from {selField}</span>
|
<span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
|
||||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto — click to add</span>}
|
{!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
placeholder="Search…"
|
placeholder={t('awrs.search')}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -294,22 +352,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
||||||
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
||||||
title="Add this reference even though it isn't in the list yet (new / unlisted)"
|
title={t('awrs.addUnlistedTitle')}
|
||||||
>
|
>
|
||||||
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||||
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||||
{busy && (
|
{busy && (
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" />Searching…
|
<Loader2 className="size-3 animate-spin" />{t('awrs.searching')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Too many auto-results → require typed search */}
|
{/* Too many auto-results → require typed search */}
|
||||||
{!busy && q.length < 2 && tooManyAuto && (
|
{!busy && q.length < 2 && tooManyAuto && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
Type 2+ chars to search
|
{t('awrs.typeToSearch')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||||
@@ -317,16 +375,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
{isDynamic && !dxcc
|
{isDynamic && !dxcc
|
||||||
? 'Enter a callsign, or type to search.'
|
? t('awrs.enterCallsignOrSearch')
|
||||||
: 'No references for this entity.'}
|
: t('awrs.noRefsForEntity')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Typed search, no results */}
|
{/* Typed search, no results */}
|
||||||
{!busy && q.length >= 2 && results.length === 0 && (
|
{!busy && q.length >= 2 && results.length === 0 && (
|
||||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||||
No results.
|
{t('awrs.noResults')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
|
|||||||
@@ -1,18 +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 { 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;
|
||||||
@@ -35,9 +41,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
const CELL_STYLE: Record<CellStatus, string> = {
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
validated: 'bg-emerald-500 text-white',
|
validated: 'bg-success text-success-foreground',
|
||||||
confirmed: 'bg-amber-400 text-amber-950',
|
confirmed: 'bg-warning text-warning-foreground',
|
||||||
worked: 'bg-stone-400 text-white',
|
worked: 'bg-muted-foreground text-background',
|
||||||
none: '',
|
none: '',
|
||||||
};
|
};
|
||||||
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||||
@@ -52,18 +58,28 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||||
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
<div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||||
|
onEditQSO?: (id: number) => void;
|
||||||
|
onAwardsChanged?: () => void;
|
||||||
|
onPaperQSL?: (call: string) => void;
|
||||||
|
} = {}) {
|
||||||
|
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('');
|
||||||
@@ -72,6 +88,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
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);
|
||||||
@@ -110,13 +130,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
// 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 {
|
||||||
@@ -124,13 +145,20 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -139,8 +167,17 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -189,17 +226,81 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
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">
|
||||||
@@ -207,23 +308,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||||
<AwardIcon className="size-4 text-primary" />
|
<AwardIcon className="size-4 text-primary" />
|
||||||
<span className="text-sm font-semibold">Awards</span>
|
<span className="text-sm font-semibold">{t('awp.awards')}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||||
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
title={t('awp.rescanTitle')}>
|
||||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
Rescan
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||||
{/* Quick selector — scales when there are many awards. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<div className="px-2 py-2 border-b border-border/40">
|
||||||
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
|
||||||
<SelectContent className="max-h-80">
|
<SelectContent className="max-h-80">
|
||||||
{awardList.map((a) => (
|
{awardList.map((a) => (
|
||||||
<SelectItem key={a.code} value={a.code} className="text-xs">
|
<SelectItem key={a.code} value={a.code} className="text-xs">
|
||||||
@@ -238,7 +339,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<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}
|
||||||
@@ -253,9 +354,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<span className="font-semibold text-sm">{a.code}</span>
|
<span className="font-semibold text-sm">{a.code}</span>
|
||||||
{r ? (
|
{r ? (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
<span className="text-emerald-600">{r.confirmed}</span>
|
<span className="text-success">{r.confirmed}</span>
|
||||||
/<span className="text-foreground">{r.worked}</span>
|
/<span className="text-foreground">{r.worked}</span>
|
||||||
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
|
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||||
@@ -273,7 +374,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{!current ? (
|
{!current ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
{loading ? 'Computing…' : 'No data'}
|
{loading ? t('awp.computing') : t('awp.noData')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -283,11 +384,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<span className="text-sm text-muted-foreground">{current.name}</span>
|
<span className="text-sm text-muted-foreground">{current.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
|
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
|
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
|
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||||
{current.total > 0 && (
|
{current.total > 0 && (
|
||||||
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||||
@@ -296,12 +397,12 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{/* Band breakdown */}
|
{/* Band breakdown */}
|
||||||
{(current.bands ?? []).length > 0 && (
|
{(current.bands ?? []).length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-border/60">
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(current.bands ?? []).map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
<span className="text-success font-mono">{b.confirmed}</span>
|
||||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -313,35 +414,51 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||||
<button key={k} onClick={() => setRefFilter(k)}
|
<button key={k} onClick={() => setRefFilter(k)}
|
||||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
<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>
|
||||||
|
{/* 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-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 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"
|
||||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
title={t('awp.missingRefsTitle')}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> Missing refs
|
<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">
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||||
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
|
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
|
||||||
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -349,15 +466,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{view === 'stats' ? (
|
{view === 'stats' ? (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
{statsLoading || !stats ? (
|
{statsLoading || !stats ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
|
||||||
) : (
|
) : (
|
||||||
<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-3 font-medium border-b border-border">Statistic</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -368,7 +485,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||||
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||||
); })}
|
); })}
|
||||||
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||||
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||||
@@ -384,8 +501,26 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<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">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">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>
|
||||||
))}
|
))}
|
||||||
@@ -395,9 +530,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{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 (
|
||||||
@@ -405,7 +541,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<button
|
||||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
)}
|
)}
|
||||||
@@ -422,11 +558,26 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<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">Ref</th>
|
<th className="py-1.5 pr-2 font-medium w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">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">Group</th>
|
{t('awp.ref')}
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
<th className="py-1.5 font-medium">Bands</th>
|
</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 font-medium">{t('awp.bands')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -436,14 +587,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||||
<td className="py-1 pr-2">
|
<td className="py-1 pr-2">
|
||||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
|
||||||
: r.confirmed ? <span className="text-amber-600">confirmed</span>
|
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
|
||||||
: <span className="text-stone-500">worked</span>}
|
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 font-mono text-muted-foreground">
|
<td className="py-1 font-mono text-muted-foreground">
|
||||||
{r.bands.map((b) => (
|
{r.bands.map((b) => (
|
||||||
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -457,7 +608,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</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} />
|
||||||
@@ -471,13 +622,19 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
// 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 [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||||
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('');
|
||||||
@@ -491,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(() => {
|
||||||
@@ -534,7 +717,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
const done = new Set(assignedIds);
|
const done = new Set(assignedIds);
|
||||||
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||||
setSel(new Set());
|
setSel(new Set());
|
||||||
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setMsg(String(e?.message ?? e));
|
setMsg(String(e?.message ?? e));
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
@@ -555,59 +738,73 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
<AlertTriangle className="size-4 text-amber-600" />
|
<AlertTriangle className="size-4 text-warning" />
|
||||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={load} disabled={loading}
|
<button onClick={load} disabled={loading}
|
||||||
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
|
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
|
||||||
title="Recompute now — contacts you've fixed drop off the list">
|
title={t('awp.recomputeTitle')}>
|
||||||
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
|
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
|
||||||
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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">{sel.size} selected →</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="Choose a reference to assign…" /></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}>
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||||
</Button>
|
</Button>
|
||||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
|
||||||
) : qsos.length === 0 ? (
|
) : qsos.length === 0 ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground">
|
<div className="p-4 text-xs text-muted-foreground">
|
||||||
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
|
{t('awp.noGaps')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||||
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
|
||||||
<SortTh k="callsign" label="Callsign" />
|
<SortTh k="callsign" label={t('awp.callsign')} />
|
||||||
<SortTh k="band" label="Band" />
|
<SortTh k="band" label={t('awp.band')} />
|
||||||
<SortTh k="mode" label="Mode" />
|
<SortTh k="mode" label={t('awp.mode')} />
|
||||||
<SortTh k="country" label="Country" />
|
<SortTh k="country" label={t('awp.country')} />
|
||||||
<SortTh k="qth" label="QTH / Note" />
|
<SortTh k="qth" label={t('awp.qthNote')} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -633,8 +830,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
|
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
|
||||||
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
|
{qsos.length} {t('awp.contactsWithoutRef')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,16 +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 [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', ' '); };
|
||||||
|
|
||||||
@@ -666,22 +871,30 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
|
||||||
) : qsos.length === 0 ? (
|
) : qsos.length === 0 ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
|
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||||
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{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,7 +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 { 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
|
||||||
@@ -21,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;
|
||||||
@@ -34,6 +87,24 @@ interface Props {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
side?: 'left' | 'right';
|
side?: 'left' | 'right';
|
||||||
onToggleSide?: () => void;
|
onToggleSide?: () => void;
|
||||||
|
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
|
||||||
|
// watering holes don't hog the map. fitToBand overrides zoom so the whole
|
||||||
|
// band edge-to-edge fits the visible height (no scrolling). Both are driven
|
||||||
|
// globally from the band-map tab toolbar.
|
||||||
|
hideDigital?: 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
|
||||||
|
// 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.
|
||||||
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -53,25 +124,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
|||||||
'70cm': [430000, 440000],
|
'70cm': [430000, 440000],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sub-band shading: CW / digital / phone.
|
||||||
|
//
|
||||||
|
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||||
|
// categorical hues — never the status tokens. They used to use success / info /
|
||||||
|
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||||
|
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||||
|
// a giant "new band" wash. Status colours are reserved for status.
|
||||||
|
//
|
||||||
|
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||||
|
// low opacity, so the band plan stays background context while the warm spot pills
|
||||||
|
// keep the foreground to themselves.
|
||||||
|
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||||
|
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||||
|
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||||
|
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||||
|
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||||
|
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||||
|
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||||
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
|
// are read on top of it.
|
||||||
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
'60m': [[5350, 5450, SEG_PHONE]],
|
||||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className={cn('size-2 rounded-full', cls)} />
|
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -79,46 +172,61 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
|
|||||||
|
|
||||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||||
// so the operator can see WHY a spot is coloured the way it is.
|
// so the operator can see WHY a spot is coloured the way it is.
|
||||||
function statusLabel(s: string): string {
|
function statusLabel(s: string, t: (k: string) => string): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return 'NEW DXCC (entity never worked)';
|
case 'new': return t('bmp.statusNew');
|
||||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
case 'new-band-mode': return t('bmp.statusNewBandMode');
|
||||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
case 'new-band': return t('bmp.statusNewBand');
|
||||||
case 'worked': return 'Worked (this band + mode already in log)';
|
case 'new-mode': return t('bmp.statusNewMode');
|
||||||
default: return 'Entity not resolved';
|
case 'new-slot': return t('bmp.statusNewSlot');
|
||||||
|
case 'new-call': return t('bmp.statusNewCall');
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
// something here you have never had", and a sixth shade in a palette read at
|
||||||
bar: 'bg-rose-500',
|
// a glance is a shade nobody can name.
|
||||||
line: 'stroke-rose-400',
|
case 'new':
|
||||||
dot: 'fill-rose-500',
|
case 'new-band-mode': return {
|
||||||
|
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||||
|
bar: 'bg-danger',
|
||||||
|
line: 'stroke-danger',
|
||||||
|
dot: 'fill-danger',
|
||||||
};
|
};
|
||||||
case 'new-band': return {
|
case 'new-band': return {
|
||||||
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||||||
bar: 'bg-amber-500',
|
bar: 'bg-warning',
|
||||||
line: 'stroke-amber-400',
|
line: 'stroke-warning',
|
||||||
dot: 'fill-amber-500',
|
dot: 'fill-warning',
|
||||||
};
|
};
|
||||||
|
case 'new-call':
|
||||||
case 'new-slot': return {
|
case 'new-slot': return {
|
||||||
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||||
bar: 'bg-yellow-500',
|
bar: 'bg-caution',
|
||||||
line: 'stroke-yellow-500',
|
line: 'stroke-caution',
|
||||||
dot: 'fill-yellow-500',
|
dot: 'fill-caution',
|
||||||
};
|
|
||||||
case 'worked': return {
|
|
||||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
|
||||||
bar: 'bg-muted-foreground/30',
|
|
||||||
line: 'stroke-border',
|
|
||||||
dot: 'fill-border',
|
|
||||||
};
|
};
|
||||||
|
case 'worked': return QUIET_STYLE;
|
||||||
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',
|
||||||
@@ -131,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)
|
||||||
@@ -146,10 +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 }: 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();
|
||||||
|
|
||||||
|
// 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(0);
|
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);
|
||||||
|
|
||||||
@@ -165,7 +335,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
const fallback: [number, number] = range ?? [0, 1];
|
const fallback: [number, number] = range ?? [0, 1];
|
||||||
const [lo, hi] = fallback;
|
const [lo, hi] = fallback;
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const pxPerKHz = PX_PER_KHZ[zoomIdx];
|
// Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
|
||||||
|
// the visible height; otherwise use the discrete zoom step.
|
||||||
|
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
|
||||||
|
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
|
||||||
|
: 0;
|
||||||
|
const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];
|
||||||
|
|
||||||
// Anti-overlap layout: each label wants to sit at its true freq, but
|
// Anti-overlap layout: each label wants to sit at its true freq, but
|
||||||
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
||||||
@@ -197,7 +372,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
// spots carry no mode word and the band-plan fallback labels them the
|
// spots carry no mode word and the band-plan fallback labels them the
|
||||||
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||||
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||||
const ftSpots = inBand.filter(isFlood);
|
// hideDigital removes them entirely; otherwise they're capped below.
|
||||||
|
const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
|
||||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||||
@@ -206,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;
|
||||||
}
|
}
|
||||||
@@ -270,48 +448,39 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||||
}
|
}
|
||||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||||
|
// In fit mode the band scale exactly fills the viewport (innerH = containerH
|
||||||
|
// − pads); when crowded sub-bands stack labels past the bottom the content
|
||||||
|
// grows and a scroll bar appears — the map defaults to the top so the whole
|
||||||
|
// band is visible, and you scroll down to reach the stacked spots.
|
||||||
return {
|
return {
|
||||||
placed: out,
|
placed: out,
|
||||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||||
hidden: hiddenCount,
|
hidden: hiddenCount,
|
||||||
};
|
};
|
||||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);
|
||||||
|
|
||||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||||
|
|
||||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
// Keep the map centred on the rig frequency: the effect re-runs only when the
|
||||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||||||
// for the scroller height to be measured and a valid in-band rig freq.
|
// while a stable frequency leaves your manual scroll alone.
|
||||||
const centeredForRef = useRef<string>('');
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
if (!range || containerH <= 0) return;
|
||||||
const kHz = currentFreqHz / 1000;
|
|
||||||
if (kHz < lo || kHz > hi) return;
|
|
||||||
if (centeredForRef.current === band) return;
|
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
centeredForRef.current = band;
|
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||||||
|
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||||||
|
if (fitToBand) { el.scrollTop = 0; return; }
|
||||||
|
if (currentFreqHz <= 0) return;
|
||||||
|
const kHz = currentFreqHz / 1000;
|
||||||
|
if (kHz < lo || kHz > hi) return;
|
||||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
// Re-centre on the rig frequency whenever the zoom level changes — zooming
|
|
||||||
// would otherwise drift away from where you're operating. Skips the initial
|
|
||||||
// mount (the band-centre effect above handles that).
|
|
||||||
const firstZoomRef = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
if (firstZoomRef.current) { firstZoomRef.current = false; return; }
|
|
||||||
const el = scrollerRef.current;
|
|
||||||
if (!el || !range || containerH <= 0) return;
|
|
||||||
const kHz = currentFreqHz && currentFreqHz / 1000 >= lo && currentFreqHz / 1000 <= hi
|
|
||||||
? currentFreqHz / 1000 : (lo + hi) / 2;
|
|
||||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [zoomIdx]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
@@ -320,26 +489,65 @@ 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]);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// 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(() => {
|
||||||
|
if (!keyNav) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
const tag = (ae?.tagName || '').toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||||
|
const list = spots
|
||||||
|
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||||
|
if (!list.length) return;
|
||||||
|
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||||
|
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||||
|
let target: Spot | undefined;
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||||
|
} else {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onSpotClick(target);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||||
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
return (
|
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="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">Band map</div>
|
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||||
Not configured for {band || '—'}.
|
{t('bmp.notConfigured', { band: band || '—' })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick step (where small marks land) and label step (where the kHz
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
// 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 per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||||
// labels every 25 kHz, not every 250.
|
// 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 tickStep = 50;
|
||||||
let labelStep = 100;
|
let labelStep = 100;
|
||||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
@@ -363,35 +571,65 @@ 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-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 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">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={zoomIdx === 0}
|
what the panel obviously is, and with four band maps side by side it
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
was four times the same word. */}
|
||||||
title="Zoom out">
|
<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"
|
||||||
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
{/* A readout, not a switch.
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
It WAS the switch for a day: clicking the px/kHz value toggled
|
||||||
title="Zoom in">
|
fit-to-band, and in fit it read "FIT". Nobody found it — a control
|
||||||
|
whose label is a measurement does not look like a control, and once
|
||||||
|
switched off there was no longer anything on screen saying the option
|
||||||
|
existed. The switches below say what they are. */}
|
||||||
|
{/* In fit mode the readout has nothing left to say — the lit FIT button
|
||||||
|
says it, and printing the word twice side by side is just noise. The
|
||||||
|
Band Map tab's cards have no button, so there the readout keeps
|
||||||
|
reporting both states. */}
|
||||||
|
{(!fitToBand || !onToggleFit) && (
|
||||||
|
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
|
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="Scroll to current rig frequency">
|
title={t('bmp.scrollToRig')}>
|
||||||
<Crosshair className="size-3" />
|
<Crosshair className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
{onToggleSide && (
|
{onToggleSide && (
|
||||||
<button type="button" onClick={onToggleSide}
|
<button type="button" onClick={onToggleSide}
|
||||||
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={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
|
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
|
||||||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" onClick={onClose}
|
<button type="button" onClick={onClose}
|
||||||
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="Hide band map">
|
title={t('bmp.hide')}>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -406,10 +644,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
height={totalH}
|
height={totalH}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{segments.map(([s, e, cls], i) => {
|
{segments.map(([s, e, colour], i) => {
|
||||||
const y1 = freqToY(Math.min(e, hi));
|
const y1 = freqToY(Math.min(e, hi));
|
||||||
const y2 = freqToY(Math.max(s, lo));
|
const y2 = freqToY(Math.max(s, lo));
|
||||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
return (
|
||||||
|
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||||
|
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -496,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)}${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}
|
||||||
@@ -515,14 +776,24 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-amber-400" label="New band" />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
<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. */}
|
||||||
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
|
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||||
|
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||||
|
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
{t('bmp.footerHint')}
|
||||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -51,35 +65,45 @@ function classMatchesMode(cls: string, mode: string): boolean {
|
|||||||
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
|
||||||
|
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
|
||||||
|
// light and dark surfaces (the generic status -muted fills were too dark on the
|
||||||
|
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
|
||||||
|
// the original emerald/indigo/stone colours exactly.
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_CLASSES: Record<string, string> = {
|
||||||
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
call_c: 'bg-mx-call-conf',
|
||||||
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
call_w: 'bg-mx-call-work',
|
||||||
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
dxcc_c: 'bg-mx-dx-conf',
|
||||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
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-emerald-700', label: 'Call confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
||||||
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
||||||
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||||
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||||
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||||
{ swatch: 'bg-stone-200', 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(
|
||||||
@@ -89,6 +113,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const dxcc = wb?.dxcc ?? 0;
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
const dxccName = wb?.dxcc_name ?? '';
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
const dxccCount = wb?.dxcc_count ?? 0;
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
|
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||||
const hasDxcc = dxcc > 0;
|
const hasDxcc = dxcc > 0;
|
||||||
const newOne = hasDxcc && dxccCount === 0;
|
const newOne = hasDxcc && dxccCount === 0;
|
||||||
@@ -104,14 +129,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||||
// lists every real (band, mode) the entity was worked on.
|
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||||
|
// count as ONE — then FT4 after FT8 is just "worked".
|
||||||
|
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||||
|
const normMode = (m: string): string => {
|
||||||
|
const u = (m || '').toUpperCase().trim();
|
||||||
|
if (!groupDigital) return u;
|
||||||
|
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||||
|
};
|
||||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
const curMode = (currentMode || '').toUpperCase().trim();
|
const curMode = normMode(currentMode);
|
||||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
// exact band+mode is NOT yet:
|
// exact band+mode is NOT yet:
|
||||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
@@ -124,30 +156,92 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const newMode = slotNew && bandWorked && !modeWorked;
|
const newMode = slotNew && bandWorked && !modeWorked;
|
||||||
const newSlot = slotNew && bandWorked && modeWorked;
|
const newSlot = slotNew && bandWorked && modeWorked;
|
||||||
|
|
||||||
|
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||||
|
// is on). Hotter colour the more wanted the entity is.
|
||||||
|
const mwBadge = mwRank > 0 ? (
|
||||||
|
<Badge
|
||||||
|
title={`ClubLog Most Wanted #${mwRank}`}
|
||||||
|
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||||
|
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||||
|
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground')}
|
||||||
|
>
|
||||||
|
MW #{mwRank}
|
||||||
|
</Badge>
|
||||||
|
) : 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(
|
||||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 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-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
<Star className="size-3 fill-current" />
|
<Star className="size-3 fill-current" />
|
||||||
NEW ONE
|
NEW ONE
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-amber-900">
|
<span className="text-xs text-warning-muted-foreground">
|
||||||
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
{' '}· never worked this entity
|
{' '}· never worked this entity
|
||||||
</span>
|
</span>
|
||||||
|
{mwBadge}
|
||||||
</>
|
</>
|
||||||
) : 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>
|
||||||
|
{mwBadge}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||||
@@ -161,10 +255,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</span>
|
</span>
|
||||||
{(newBand || newMode || newBandMode || newSlot) && (
|
{(newBand || newMode || newBandMode || newSlot) && (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
{newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
{newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
{newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -178,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">
|
||||||
@@ -217,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-stone-200 hover:bg-stone-300',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
isCurrent && 'ring-2 ring-amber-500 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',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -240,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-amber-500 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { BulkUpdateField } from '../../wailsjs/go/main/App';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||||
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
|
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||||
|
// the backend BulkUpdateField whitelist.
|
||||||
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
|
const FIELDS: FieldDef[] = [
|
||||||
|
// QSL / upload status
|
||||||
|
// One channel at a time, each with its status and its date, in the same order
|
||||||
|
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||||
|
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||||
|
// status" or "… date", which is also what the ADIF field is called.
|
||||||
|
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ 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_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_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ 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_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
|
||||||
|
{ 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 },
|
||||||
|
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||||
|
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
|
// Contest
|
||||||
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
// Propagation / satellite
|
||||||
|
{ id: 'prop_mode', label: 'bulk.fPropMode', 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 },
|
||||||
|
// 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: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||||
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', 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_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
|
||||||
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
|
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
|
||||||
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// label holds an i18n key.
|
||||||
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||||
|
{ v: 'Y', label: 'bulk.statusY' },
|
||||||
|
{ v: 'N', label: 'bulk.statusN' },
|
||||||
|
{ v: 'R', label: 'bulk.statusR' },
|
||||||
|
{ v: 'I', label: 'bulk.statusI' },
|
||||||
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
|
'My station': 'bulk.groupMyStation',
|
||||||
|
'Contacted station': 'bulk.groupContacted',
|
||||||
|
'Contest': 'bulk.groupContest',
|
||||||
|
'Propagation': 'bulk.groupPropagation',
|
||||||
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
ids: number[];
|
||||||
|
onClose: () => void;
|
||||||
|
onApplied: (n: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// BulkEditModal sets one QSL/upload status field to a chosen value across the
|
||||||
|
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||||
|
// so they become eligible for upload.
|
||||||
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [field, setField] = useState('qsl_sent');
|
||||||
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
|
||||||
|
const isStatus = def.kind === 'status';
|
||||||
|
const effectiveValue = isStatus
|
||||||
|
? (statusValue === '_' ? '' : statusValue)
|
||||||
|
: textValue.trim();
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const n = await BulkUpdateField(ids as any, field, effectiveValue);
|
||||||
|
onApplied(Number(n) || 0);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('bulk.desc', { n: ids.length })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-5 py-2 space-y-3">
|
||||||
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
|
||||||
|
<Select value={field} onValueChange={setField}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<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)
|
||||||
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
.map(({ f, txt }) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
||||||
|
{isStatus ? (
|
||||||
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : def.kind === 'date' ? (
|
||||||
|
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||||
|
// writes an empty value — which is how a wrong date is removed
|
||||||
|
// from a batch, the same meaning as leaving a text field blank.
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||||
|
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||||
|
title={t('bulk.todayUtc')}
|
||||||
|
onClick={() => {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: the whole log is UTC, and near midnight the local day
|
||||||
|
// is the wrong one.
|
||||||
|
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||||
|
}}
|
||||||
|
>{t('bulk.today')}</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={textValue}
|
||||||
|
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
|
||||||
|
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
|
||||||
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||||
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
|
||||||
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
{t('bulk.applyTo', { n: ids.length })}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +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 { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -26,6 +28,7 @@ function fmtDateTime(s: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const hasCall = currentCall.trim() !== '';
|
const hasCall = currentCall.trim() !== '';
|
||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
const entries = wb?.entries ?? [];
|
||||||
@@ -35,7 +38,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Worked before
|
{t('chp.workedBefore')}
|
||||||
</span>
|
</span>
|
||||||
{hasCall && (
|
{hasCall && (
|
||||||
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||||
@@ -52,54 +55,54 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
|
|
||||||
{!hasCall ? (
|
{!hasCall ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||||
Type a callsign to see prior contacts.
|
{t('chp.typeCallsign')}
|
||||||
</div>
|
</div>
|
||||||
) : busy && count === 0 ? (
|
) : busy && count === 0 ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||||
checking…
|
{t('chp.checking')}
|
||||||
</div>
|
</div>
|
||||||
) : count === 0 ? (
|
) : count === 0 ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||||
<Star className="size-6 text-primary fill-current" />
|
<Star className="size-6 text-primary fill-current" />
|
||||||
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
|
||||||
<div>No prior QSO with this callsign.</div>
|
<div>{t('chp.noPriorQso')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||||||
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||||
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-[11px] border-collapse">
|
<table className="w-full text-[11px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.id} className="hover:bg-stone-50">
|
<tr key={e.id} className="hover:bg-card">
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
|
||||||
</td>
|
</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_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-white bg-blue-600 mr-0.5" title="LoTW rcvd">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-white bg-emerald-600 mr-0.5" title="Bureau rcvd">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>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -108,7 +111,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
</table>
|
</table>
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||||||
+ {count - entries.length} older QSOs
|
{t('chp.olderQsos', { n: count - entries.length })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||||
|
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||||
|
|
||||||
|
function hhmm(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
|
||||||
|
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
|
||||||
|
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||||
|
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||||
|
onSend: (text: string) => void; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const me = (myCall || '').toUpperCase();
|
||||||
|
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
setText('');
|
||||||
|
onSend(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col 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 shrink-0">
|
||||||
|
<MessageSquare className="size-4 text-info" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
{/* Online count — hover to see who's connected. */}
|
||||||
|
<div className="relative group">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||||
|
<Users className="size-3.5" />{online.length}
|
||||||
|
</span>
|
||||||
|
{online.length > 0 && (
|
||||||
|
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
|
||||||
|
{online.map((o) => (
|
||||||
|
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||||
|
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||||
|
{msgs.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6">{t('chatp.noMessages')}</div>
|
||||||
|
) : msgs.map((m) => {
|
||||||
|
const mine = m.operator.toUpperCase() === me;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||||
|
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-info-muted text-info-muted-foreground' : 'bg-muted')}>
|
||||||
|
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||||
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
|
placeholder={t('chatp.messagePh')}
|
||||||
|
maxLength={1000}
|
||||||
|
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={send} disabled={!text.trim()}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
|
||||||
|
<Send className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
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 { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -11,31 +12,16 @@ 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 { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 12.5,
|
|
||||||
backgroundColor: '#faf6ea',
|
|
||||||
foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9',
|
|
||||||
headerTextColor: '#5a4f3a',
|
|
||||||
headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0',
|
|
||||||
rowHoverColor: '#ecdcb4',
|
|
||||||
selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994',
|
|
||||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10,
|
|
||||||
rowHeight: 30,
|
|
||||||
headerHeight: 32,
|
|
||||||
spacing: 4,
|
|
||||||
accentColor: '#b8410c',
|
|
||||||
iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ClusterSpot = {
|
export type ClusterSpot = {
|
||||||
source_id: number;
|
source_id: number;
|
||||||
@@ -67,12 +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;
|
||||||
|
// 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_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';
|
||||||
@@ -108,217 +117,433 @@ 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
|
||||||
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
// 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
|
||||||
|
|
||||||
|
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||||
|
//
|
||||||
|
// This is not a return to the pills that were dropped. A pill is a box INSIDE
|
||||||
|
// the cell: it has its own height and padding, so it sat off the row baseline
|
||||||
|
// and the callsign inside it stopped lining up with the plain callsigns above
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||||
|
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||||
|
// rather than as a gap.
|
||||||
|
function cellText(value: any, color: string | null): any {
|
||||||
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
|
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
|
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
case 'new':
|
||||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
case 'new-band-mode':
|
||||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
case 'new-band':
|
||||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
case 'new-mode':
|
||||||
|
case 'new-slot':
|
||||||
|
case 'new-call':
|
||||||
|
return NEW;
|
||||||
|
default:
|
||||||
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
// 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[] => [
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Time', colId: 'time',
|
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||||
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
cellStyle: { color: '#7a6b50' },
|
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
|
||||||
|
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
|
||||||
|
// below "2359Z", so spots received just after midnight fell to the bottom
|
||||||
|
// and looked like the cluster had stopped. received_at is a full datetime
|
||||||
|
// that keeps ordering correct across 0000Z.
|
||||||
|
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
|
||||||
|
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
|
||||||
|
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
|
||||||
|
return ta - tb;
|
||||||
|
},
|
||||||
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Call', colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: '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 entity → fill the whole cell (no padded pill, so calls stay
|
// NEW DXCC fills the call cell. Already worked only tints the text blue:
|
||||||
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
// 'worked' is not a novelty, and filling it would wash most of the rows.
|
||||||
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
|
||||||
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
|
||||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
|
||||||
tooltipValueGetter: (p: any) => {
|
|
||||||
const s = statusFor(p);
|
|
||||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Spot', label: 'Status', colId: 'status',
|
|
||||||
headerName: 'Status', width: 96, sortable: true,
|
|
||||||
defaultVisible: true,
|
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
|
||||||
// is obvious at the row level, not just a single coloured cell.
|
|
||||||
valueGetter: (p: any) => {
|
|
||||||
const s = statusFor(p);
|
|
||||||
if (s?.status === 'new') return 'NEW DXCC';
|
|
||||||
if (s?.status === 'new-band') return 'NEW BAND';
|
|
||||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
|
||||||
return s?.worked_call ? 'WKD CALL' : '';
|
|
||||||
},
|
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(statusFor(p));
|
const s = statusFor(p);
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
|
||||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
{p.value ?? ''}
|
||||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
{/* A single letter rather than a word: the column is 120 px and holds a
|
||||||
}}>{b.text}</span>
|
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);
|
||||||
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||||
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
},
|
||||||
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
},
|
||||||
if (s?.worked_call) return 'Already worked this call';
|
{
|
||||||
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||||
|
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||||
|
// 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) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
const parts: string[] = [];
|
||||||
|
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-mode') parts.push(t('clg2.newMode'));
|
||||||
|
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'));
|
||||||
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
|
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(' ');
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
const parts: { text: string; color: string; dim?: boolean }[] = [];
|
||||||
|
const main = statusColor(s);
|
||||||
|
if (main) {
|
||||||
|
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||||
|
: s?.status === 'new-band-mode' ? t('clg2.newBandMode')
|
||||||
|
: 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 (
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{parts.map((pt, i) => (
|
||||||
|
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
|
||||||
|
{i > 0 ? ' · ' : ''}{pt.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
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-slot') return t('clg2.tipNewSlotBand');
|
||||||
|
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
||||||
|
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: '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: '#166534' },
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
? 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),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
|
||||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Band', colId: 'band',
|
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
|
||||||
headerName: '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 → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
// it too: the band really is new, and leaving it plain would say the
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
// opposite of the status column beside it.
|
||||||
: undefined),
|
cellStyle: (p: any) => {
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
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: 'Mode', colId: 'mode',
|
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
|
||||||
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) : '',
|
||||||
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
// NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
// genuinely new for the entity. NEW SLOT means band AND mode were each worked
|
||||||
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
// before (just not together), so highlighting the mode cell would wrongly
|
||||||
: undefined),
|
// imply "CW is new"; that case is signalled by the Status badge alone.
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
cellStyle: (p: any) => {
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
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) => {
|
||||||
|
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-slot') return t('clg2.tipNewSlot');
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: '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: '#7a6b50' },
|
// 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: 'CQ Zone', colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.grid'), colId: 'grid',
|
||||||
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
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',
|
||||||
|
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
|
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
|
||||||
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
|
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
|
||||||
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
|
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
|
||||||
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
|
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
|
||||||
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
|
||||||
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
comparator: (a, b) => (a ?? 0) - (b ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Country', colId: 'country',
|
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
|
||||||
headerName: 'Country', width: 140,
|
headerName: t('clg2.c.country'), width: 140,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.country ?? 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)
|
||||||
]?.country ?? '',
|
]?.country ?? '',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Continent', colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
headerName: 'Cont', width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.continent ?? 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)
|
||||||
]?.continent ?? '',
|
]?.continent ?? '',
|
||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Spotter', colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Source', colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: 'Source', field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Locator', colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Comment', colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Received at', colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
|
||||||
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
valueFormatter: (p) => fmtDateTimeUTC(p.value),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Raw', colId: 'raw',
|
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
|
||||||
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_ORDER = ['Spot', 'Geo'];
|
const GROUP_ORDER = ['Spot', 'Geo'];
|
||||||
|
|
||||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
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);
|
||||||
|
|
||||||
|
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
|
|
||||||
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
|
// single language change overwrote the saved cluster layout — in the cache AND
|
||||||
|
// in the database, with nothing to restore from.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
|
return COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), []);
|
});
|
||||||
|
}, [COL_CATALOG]);
|
||||||
|
|
||||||
|
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(tm);
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
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
|
||||||
@@ -327,22 +552,85 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// change below.
|
// change below.
|
||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||||
|
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||||
|
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||||
|
// 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(() => {
|
||||||
|
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||||
|
refreshPending.current = window.setTimeout(() => {
|
||||||
|
refreshPending.current = undefined;
|
||||||
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
|
}, 200);
|
||||||
|
}, [spotStatus]);
|
||||||
|
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
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) 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,11 +672,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title="Clear all column filters">
|
title={t('clg2.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> Clear filters
|
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> {t('clg2.columns')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
@@ -396,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}
|
||||||
@@ -407,21 +695,42 @@ 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}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Cluster columns</DialogTitle>
|
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Cluster table.
|
{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);
|
||||||
@@ -429,10 +738,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
@@ -451,8 +760,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
|
||||||
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
|
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Trophy } from 'lucide-react';
|
||||||
|
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
|
||||||
|
// so a running contest (serial counter included) survives a restart.
|
||||||
|
export type ContestSession = {
|
||||||
|
active: boolean;
|
||||||
|
code: string; // ADIF CONTEST_ID written to each QSO
|
||||||
|
name: string;
|
||||||
|
exchange: 'serial' | 'fixed';
|
||||||
|
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
|
||||||
|
sentFixed: string; // the value YOU send when the exchange is fixed
|
||||||
|
nextSerial: number; // running sent serial number
|
||||||
|
startISO: string; // contest window start (UTC ISO) — only QSOs after count
|
||||||
|
endISO: string; // contest window end (UTC ISO, empty = open)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTEST_DEFAULT: ContestSession = {
|
||||||
|
active: false, code: '', name: '', exchange: 'serial',
|
||||||
|
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
|
||||||
|
startISO: '', endISO: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contests run on UTC, so the date/time inputs are treated as UTC.
|
||||||
|
const utcInputToISO = (v: string) => (v ? new Date(v + ':00Z').toISOString() : '');
|
||||||
|
const isoToUtcInput = (iso: string) => (iso ? new Date(iso).toISOString().slice(0, 16) : '');
|
||||||
|
|
||||||
|
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
|
||||||
|
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Def = { code: string; name: string; exchange: string };
|
||||||
|
type Stats = { qsos: number; by_band?: { band: string; count: number }[] | null; mult: number; mult_label: string; score: number; last_hour: number };
|
||||||
|
|
||||||
|
function Stat({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card px-3 py-2 text-center">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||||
|
<div className="text-xl font-black tabular-nums" style={accent ? { color: accent } : undefined}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContestPanel({ session, onChange }: {
|
||||||
|
session: ContestSession;
|
||||||
|
onChange: (patch: Partial<ContestSession>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [contests, setContests] = useState<Def[]>([]);
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// Poll the scoreboard while a contest is running.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.active || !session.code) { setStats(null); return; }
|
||||||
|
let alive = true;
|
||||||
|
const load = () => ContestStats(
|
||||||
|
session.code,
|
||||||
|
session.exchange === 'fixed' ? kindKey(session.fixedKind) : 'serial',
|
||||||
|
session.startISO, session.endISO,
|
||||||
|
).then((s) => { if (alive) setStats(s as Stats); }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 4000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [session.active, session.code, session.exchange, session.fixedKind, session.startISO, session.endISO]);
|
||||||
|
|
||||||
|
const pickContest = (code: string) => {
|
||||||
|
const def = contests.find((c) => c.code === code);
|
||||||
|
if (!def) { onChange({ code: '', name: '' }); return; }
|
||||||
|
if (def.exchange === 'serial' || def.exchange === 'rst') {
|
||||||
|
onChange({ code: def.code, name: def.name, exchange: 'serial' });
|
||||||
|
} else {
|
||||||
|
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Setup card. */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||||
|
<Trophy className="size-4 text-warning" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
||||||
|
{session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
|
||||||
|
<option value="">{t('ctp.chooseContest')}</option>
|
||||||
|
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{(['serial', 'fixed'] as const).map((x) => (
|
||||||
|
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
|
||||||
|
className={cn('px-3 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||||
|
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{x === 'serial' ? t('ctp.serial') : t('ctp.fixedExchange')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.exchange === 'serial' ? (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('ctp.nextSerial')}
|
||||||
|
<input type="number" min={1} value={session.nextSerial}
|
||||||
|
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
|
||||||
|
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm">
|
||||||
|
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||||
|
</select>
|
||||||
|
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
|
||||||
|
placeholder={t('ctp.yourExchangePh')} className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contest window (UTC). Only QSOs inside it count toward the score and
|
||||||
|
dupe check — so old runs of the same contest aren't included. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('ctp.startUtc')}
|
||||||
|
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
|
||||||
|
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('ctp.endUtc')}
|
||||||
|
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
|
||||||
|
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
{session.active ? (
|
||||||
|
<button type="button" onClick={() => onChange({ active: false })}
|
||||||
|
className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
|
||||||
|
{t('ctp.stopContest')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={!session.code}
|
||||||
|
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
||||||
|
className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
|
||||||
|
{t('ctp.startContest')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scoreboard. */}
|
||||||
|
{session.active && (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.scoreboard')}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
|
||||||
|
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
|
||||||
|
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
|
||||||
|
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
|
||||||
|
</div>
|
||||||
|
{stats && stats.by_band && stats.by_band.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
|
||||||
|
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.by_band.map((r) => (
|
||||||
|
<tr key={r.band} className="border-t border-border/60">
|
||||||
|
<td className="px-3 py-1 font-mono">{r.band}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{r.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindKey maps a fixed-kind label back to the backend's exchange key for mult
|
||||||
|
// counting.
|
||||||
|
function kindKey(label: string): string {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
'CQ Zone': 'cq-zone', 'ITU Zone': 'itu-zone', 'State/Prov': 'state',
|
||||||
|
'Département': 'dept', 'Grid': 'grid', 'Name': 'name', 'Section': 'section', 'Age': 'age',
|
||||||
|
};
|
||||||
|
return m[label] || 'state';
|
||||||
|
}
|
||||||
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';
|
||||||
@@ -7,6 +7,8 @@ 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 { 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';
|
||||||
@@ -35,9 +37,13 @@ export interface DetailsState {
|
|||||||
sat_name: string;
|
sat_name: string;
|
||||||
sat_mode: string;
|
sat_mode: string;
|
||||||
contest_id: string;
|
contest_id: string;
|
||||||
srx?: number;
|
// Contest exchanges as free text — most are serials (001) but some are
|
||||||
stx?: number;
|
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
|
||||||
|
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
|
||||||
|
srx_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.
|
||||||
@@ -49,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.
|
||||||
@@ -57,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).
|
||||||
@@ -65,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';
|
||||||
@@ -72,35 +100,35 @@ export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
|||||||
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||||
const PROP_MODES: { value: string; label: string }[] = [
|
const PROP_MODES: { value: string; label: string }[] = [
|
||||||
{ value: 'NONE', label: '—' },
|
{ value: 'NONE', label: '—' },
|
||||||
{ value: 'AS', label: 'Aircraft Scatter' },
|
{ value: 'AS', label: 'detp.propAS' },
|
||||||
{ value: 'AUR', label: 'Aurora' },
|
{ value: 'AUR', label: 'detp.propAUR' },
|
||||||
{ value: 'AUE', label: 'Aurora-E' },
|
{ value: 'AUE', label: 'detp.propAUE' },
|
||||||
{ value: 'BS', label: 'Back Scatter' },
|
{ value: 'BS', label: 'detp.propBS' },
|
||||||
{ value: 'ECH', label: 'EchoLink' },
|
{ value: 'ECH', label: 'EchoLink' },
|
||||||
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
{ value: 'EME', label: 'detp.propEME' },
|
||||||
{ value: 'ES', label: 'Sporadic E' },
|
{ value: 'ES', label: 'detp.propES' },
|
||||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
{ value: 'FAI', label: 'detp.propFAI' },
|
||||||
{ value: 'F2', label: 'F2 Reflection' },
|
{ value: 'F2', label: 'detp.propF2' },
|
||||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
{ value: 'GWAVE', label: 'detp.propGWAVE' },
|
||||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
{ value: 'INTERNET', label: 'detp.propINTERNET' },
|
||||||
{ value: 'ION', label: 'Ionoscatter' },
|
{ value: 'ION', label: 'detp.propION' },
|
||||||
{ value: 'IRL', label: 'IRLP' },
|
{ value: 'IRL', label: 'IRLP' },
|
||||||
{ value: 'LOS', label: 'Line of Sight' },
|
{ value: 'LOS', label: 'detp.propLOS' },
|
||||||
{ value: 'MS', label: 'Meteor Scatter' },
|
{ value: 'MS', label: 'detp.propMS' },
|
||||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
{ value: 'RPT', label: 'detp.propRPT' },
|
||||||
{ value: 'RS', label: 'Rain Scatter' },
|
{ value: 'RS', label: 'detp.propRS' },
|
||||||
{ value: 'SAT', label: 'Satellite' },
|
{ value: 'SAT', label: 'detp.propSAT' },
|
||||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
{ value: 'TEP', label: 'detp.propTEP' },
|
||||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
{ value: 'TR', label: 'detp.propTR' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||||
const ANT_PATHS: { value: string; label: string }[] = [
|
const ANT_PATHS: { value: string; label: string }[] = [
|
||||||
{ value: 'NONE', label: '—' },
|
{ value: 'NONE', label: '—' },
|
||||||
{ value: 'S', label: 'Short Path' },
|
{ value: 'S', label: 'detp.pathShort' },
|
||||||
{ value: 'L', label: 'Long Path' },
|
{ value: 'L', label: 'detp.pathLong' },
|
||||||
{ value: 'G', label: 'Grayline' },
|
{ value: 'G', label: 'detp.pathGrayline' },
|
||||||
{ value: 'O', label: 'Other' },
|
{ value: 'O', label: 'detp.pathOther' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function numOrUndef(v: string): number | undefined {
|
function numOrUndef(v: string): number | undefined {
|
||||||
@@ -109,27 +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 [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 () => {
|
||||||
@@ -137,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(),
|
||||||
};
|
};
|
||||||
@@ -146,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); }
|
||||||
}
|
}
|
||||||
@@ -155,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(() => {
|
||||||
@@ -169,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`;
|
||||||
|
|
||||||
@@ -188,11 +259,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: TabName; label: string }[] = [
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
{ key: 'stats', label: `Stats (${fk}1)` },
|
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
|
||||||
{ key: 'info', label: `Info (${fk}2)` },
|
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
|
||||||
{ key: 'awards', label: `Awards (${fk}3)` },
|
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
|
||||||
{ key: 'my', label: `My (${fk}4)` },
|
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
|
||||||
{ key: 'extended', label: `Extended (${fk}5)` },
|
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -214,36 +285,57 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<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="State / pref">
|
{/* 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="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="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="CQ zone">
|
<Field label={t('detp.cqZone')}>
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="ITU zone">
|
<Field label={t('detp.ituZone')}>
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* 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="DXCC #">
|
<Field label={t('detp.dxcc')} span={2}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -252,7 +344,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Azimuth LP">
|
{/* 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}
|
||||||
@@ -261,7 +357,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Distance SP">
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -270,7 +366,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Distance LP">
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -279,15 +375,20 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="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="QSL message" 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="QSL via" 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -300,12 +401,39 @@ 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
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
several matched, so none was kept. Click one to settle it while the
|
||||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
contact is still in front of you. */}
|
||||||
{detected.map((r) => (
|
{detected.some((r) => r.ambiguous) && (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
<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">
|
||||||
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
|
{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 className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -315,50 +443,68 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
|
|
||||||
{open === 'my' && (
|
{open === 'my' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
<Field label="Azimuth (°)">
|
<Field label={t('detp.azimuth')}>
|
||||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Elevation (°)">
|
<Field label={t('detp.elevation')}>
|
||||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="TX power (W)">
|
<Field label={t('detp.txPower')}>
|
||||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="col-span-3 flex items-end pb-1.5">
|
<div className="col-span-3 flex items-end pb-1.5">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||||
Satellite mode
|
{t('detp.satelliteMode')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Ant. path" span={2}>
|
<Field label={t('detp.antPath')} span={2}>
|
||||||
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Propagation" span={4}>
|
<Field label={t('detp.propagation')} span={4}>
|
||||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="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="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="Satellite name" 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="Satellite mode" 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -367,17 +513,23 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
|
|
||||||
{open === 'extended' && (
|
{open === 'extended' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
<Field label="Contest ID" span={2}>
|
<Field label={t('detp.contestId')} span={2}>
|
||||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="SRX">
|
<Field label="SRX">
|
||||||
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Contacted email" 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,162 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Copy, Loader2, Trash2, X } from 'lucide-react';
|
||||||
|
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type QSO = {
|
||||||
|
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
|
||||||
|
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
|
||||||
|
};
|
||||||
|
type Group = { key: string; qsos: QSO[] };
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
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())}`;
|
||||||
|
};
|
||||||
|
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
|
||||||
|
|
||||||
|
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
|
||||||
|
// same day; optionally same minute), groups them, and lets the operator pick
|
||||||
|
// which copies to delete. The oldest in each group is left unchecked by default.
|
||||||
|
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = (w: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
FindDuplicates(w)
|
||||||
|
.then((g) => {
|
||||||
|
const gs = (g ?? []) as Group[];
|
||||||
|
setGroups(gs);
|
||||||
|
// Default: select every QSO except the first (oldest) of each group.
|
||||||
|
const next = new Set<number>();
|
||||||
|
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
|
||||||
|
setSel(next);
|
||||||
|
})
|
||||||
|
.catch(() => { setGroups([]); setSel(new Set()); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
|
||||||
|
|
||||||
|
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
|
||||||
|
const deselectAll = () => setSel(new Set());
|
||||||
|
|
||||||
|
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (sel.size === 0) return;
|
||||||
|
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await DeleteQSOs(Array.from(sel) as any);
|
||||||
|
onDeleted();
|
||||||
|
load(windowMin); // rescan so the list reflects what's left
|
||||||
|
} catch { /* ignore */ } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
|
||||||
|
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||||
|
<Copy className="size-4 text-primary" />
|
||||||
|
<span className="font-bold">{t('dup.title')}</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="px-5 py-3 border-b border-border/60 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('dup.window')}
|
||||||
|
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
|
||||||
|
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||||
|
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
|
||||||
|
<option value={0}>{t('dup.sameDay')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
|
||||||
|
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
|
||||||
|
</div>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => (
|
||||||
|
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
|
||||||
|
<span className="font-mono">{g.qsos[0].callsign}</span>
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<tr className="border-b border-border/60">
|
||||||
|
<th className="w-8"></th>
|
||||||
|
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
|
||||||
|
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
|
||||||
|
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
|
||||||
|
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{g.qsos.map((q, i) => {
|
||||||
|
const checked = sel.has(q.id);
|
||||||
|
return (
|
||||||
|
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
|
||||||
|
<td className="text-center py-1">
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||||
|
{fmtDate(q.qso_date)}
|
||||||
|
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
||||||
|
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
||||||
|
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
||||||
|
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||||
|
{t('dup.deleteSel', { n: sel.size })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||||
@@ -11,25 +12,32 @@ type Props = {
|
|||||||
onPlay: (slot: number) => void;
|
onPlay: (slot: number) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoCq: boolean;
|
||||||
|
autoCqSecs: number;
|
||||||
|
onToggleAutoCq: (on: boolean) => void;
|
||||||
|
onSetAutoCqSecs: (n: number) => void;
|
||||||
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
const anyAudio = messages.some((m) => m.has_audio);
|
const { t } = useI18n();
|
||||||
|
const recorded = messages.filter((m) => m.has_audio);
|
||||||
|
const anyAudio = recorded.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> Stop
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,32 +46,57 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
{!anyAudio ? (
|
{!anyAudio ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
||||||
<Radio className="size-5 opacity-50" />
|
<Radio className="size-5 opacity-50" />
|
||||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-1">
|
// Only the recorded slots are shown. Two columns filling top-to-bottom
|
||||||
{messages.map((m) => (
|
// (rows sized to the count) — use the width instead of scrolling, so the
|
||||||
|
// panel keeps the same height as the other reserved-area widgets.
|
||||||
|
<div className="grid grid-flow-col gap-1"
|
||||||
|
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
|
||||||
|
{recorded.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.slot}
|
key={m.slot}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!m.has_audio}
|
disabled={!phoneOk}
|
||||||
onClick={() => onPlay(m.slot)}
|
onClick={() => onPlay(m.slot)}
|
||||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||||
m.has_audio
|
phoneOk
|
||||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? 'message' : '—')}</span>
|
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
|
||||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
|
||||||
|
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
|
||||||
|
non-phone mode (the DVK won't transmit there). */}
|
||||||
|
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
|
||||||
|
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
|
||||||
|
onChange={(e) => onToggleAutoCq(e.target.checked)} />
|
||||||
|
<Repeat className="size-3" /> {t('dvkp.autoCq')}
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
|
||||||
|
<input type="number" min={0} max={120} value={autoCqSecs}
|
||||||
|
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
|
||||||
|
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!phoneOk && (
|
||||||
|
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
|
||||||
|
)}
|
||||||
</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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||||
|
import { adif } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type FieldDef = adif.FieldDef;
|
||||||
|
const PREF_KEY = 'opslog.exportFields';
|
||||||
|
|
||||||
|
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
|
||||||
|
// inside ExportFieldsDialog) so its component identity is stable across renders —
|
||||||
|
// an inner component is re-created every render, remounting the whole subtree and
|
||||||
|
// making the All/None buttons and checkboxes feel dead.
|
||||||
|
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
|
||||||
|
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
|
||||||
|
allLabel: string; noneLabel: string;
|
||||||
|
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||||
|
<span className="flex gap-1.5 shrink-0">
|
||||||
|
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
|
||||||
|
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tags.map((name) => (
|
||||||
|
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||||
|
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
|
||||||
|
<span className="font-mono break-all">{name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||||
|
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||||
|
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||||
|
// chosen set is returned to the parent (which runs the selected/global export)
|
||||||
|
// and persisted so it's the default next time.
|
||||||
|
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||||
|
open: boolean;
|
||||||
|
count: number; // how many QSOs the pending export covers
|
||||||
|
onExport: (fields: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||||
|
const [extras, setExtras] = useState<string[]>([]);
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const defaultSet = (flds: FieldDef[]) =>
|
||||||
|
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const flds = (f ?? []) as FieldDef[];
|
||||||
|
setOfficial(flds);
|
||||||
|
setExtras((e ?? []) as string[]);
|
||||||
|
let saved: string[] | null = null;
|
||||||
|
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||||
|
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||||
|
})().catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const m = new Map<string, FieldDef[]>();
|
||||||
|
for (const d of official) {
|
||||||
|
if (d.deprecated) continue;
|
||||||
|
const g = d.category || 'Misc';
|
||||||
|
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||||
|
}
|
||||||
|
return [...m.entries()];
|
||||||
|
}, [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) =>
|
||||||
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
|
const setMany = (names: string[], on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
const fields = [...sel];
|
||||||
|
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||||
|
onExport(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allLabel = t('exf.all');
|
||||||
|
const noneLabel = t('exf.none');
|
||||||
|
const cardProps = {
|
||||||
|
sel, allLabel, noneLabel,
|
||||||
|
onAll: (tags: string[]) => setMany(tags, true),
|
||||||
|
onNone: (tags: string[]) => setMany(tags, false),
|
||||||
|
onToggle: toggle,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
|
</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">
|
||||||
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
|
<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')}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||||
|
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
|
||||||
|
{groups.map(([g, list]) => (
|
||||||
|
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||||
|
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, 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';
|
||||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||||
// adds field/operator/value conditions, joins them with AND or OR, and can
|
// adds field/operator/value conditions, joins them with AND or OR, and can
|
||||||
@@ -12,7 +13,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -25,79 +27,126 @@ export interface QueryFilter {
|
|||||||
|
|
||||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||||
type FieldType = 'text' | 'number' | 'date';
|
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||||
|
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||||
|
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: 'Callsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
|
// When the record entered the logbook — the way to isolate what an import added.
|
||||||
{ value: 'band', label: 'Band', type: 'text' },
|
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||||
{ value: 'band_rx', label: 'RX band', type: 'text' },
|
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||||
{ value: 'mode', label: 'Mode', type: 'text' },
|
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||||
{ value: 'submode', label: 'Submode', type: 'text' },
|
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||||
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
|
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
|
||||||
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
|
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
|
||||||
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
|
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
|
||||||
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
|
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
|
||||||
{ value: 'name', label: 'Name', type: 'text' },
|
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
|
||||||
{ value: 'qth', label: 'QTH', type: 'text' },
|
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
|
||||||
{ value: 'address', label: 'Address', type: 'text' },
|
{ value: 'name', label: 'fltb.fName', type: 'text' },
|
||||||
{ value: 'email', label: 'E-mail', type: 'text' },
|
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
|
||||||
{ value: 'grid', label: 'Grid', type: 'text' },
|
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
|
||||||
{ value: 'country', label: 'Country', type: 'text' },
|
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
|
||||||
{ value: 'state', label: 'State', type: 'text' },
|
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
|
||||||
{ value: 'cnty', label: 'County', type: 'text' },
|
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
|
||||||
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
|
{ value: 'state', label: 'fltb.fState', type: 'text' },
|
||||||
{ value: 'cont', label: 'Continent', type: 'text' },
|
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
|
||||||
{ value: 'cqz', label: 'CQ zone', type: 'number' },
|
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
|
||||||
{ value: 'ituz', label: 'ITU zone', type: 'number' },
|
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
|
||||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
|
||||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
|
||||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
|
||||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
|
||||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
|
||||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||||
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
|
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||||
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
|
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
|
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||||
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
|
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||||
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
|
// of them — a paper QSL is not uploaded.
|
||||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
|
||||||
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
|
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
|
||||||
{ value: 'sat_name', label: 'Satellite', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
|
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||||
{ value: 'operator', label: 'Operator', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||||
|
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||||
|
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
|
{ 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: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
|
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
|
||||||
|
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
|
||||||
|
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
|
||||||
|
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
|
||||||
|
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
|
||||||
|
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
|
||||||
|
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
|
||||||
|
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
|
||||||
|
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
|
||||||
|
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
|
||||||
|
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
|
||||||
|
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
|
||||||
|
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
|
||||||
|
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
|
||||||
|
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
|
||||||
|
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
|
||||||
|
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
|
||||||
|
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
|
||||||
|
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
|
||||||
|
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
|
||||||
|
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPS: { value: FilterOp; label: string }[] = [
|
const OPS: { value: FilterOp; label: string }[] = [
|
||||||
{ value: 'eq', label: 'equals (=)' },
|
{ value: 'eq', label: 'fltb.opEq' },
|
||||||
{ value: 'ne', label: 'not equal (≠)' },
|
{ value: 'ne', label: 'fltb.opNe' },
|
||||||
{ value: 'contains', label: 'contains' },
|
{ value: 'contains', label: 'fltb.opContains' },
|
||||||
{ value: 'startswith', label: 'starts with' },
|
{ value: 'startswith', label: 'fltb.opStartsWith' },
|
||||||
{ value: 'endswith', label: 'ends with' },
|
{ value: 'endswith', label: 'fltb.opEndsWith' },
|
||||||
{ value: 'gt', label: 'greater than (>)' },
|
{ value: 'gt', label: 'fltb.opGt' },
|
||||||
{ value: 'lt', label: 'less than (<)' },
|
{ value: 'lt', label: 'fltb.opLt' },
|
||||||
{ value: 'ge', label: 'greater or equal (≥)' },
|
{ value: 'ge', label: 'fltb.opGe' },
|
||||||
{ value: 'le', label: 'less or equal (≤)' },
|
{ value: 'le', label: 'fltb.opLe' },
|
||||||
{ value: 'empty', label: 'is empty' },
|
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||||
{ value: 'notempty', label: 'is not empty' },
|
{ 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 }[] {
|
||||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||||
|
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||||
|
// comparison operators (before/after), not the text ones.
|
||||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||||
return OPS.filter((o) => allow.includes(o.value));
|
return OPS.filter((o) => allow.includes(o.value));
|
||||||
}
|
}
|
||||||
@@ -119,6 +168,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
|
||||||
|
// grouped by category for maintenance; the operator wants it A→Z to find things).
|
||||||
|
const sortedFields = useMemo(
|
||||||
|
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||||
@@ -144,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;
|
||||||
@@ -151,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];
|
||||||
@@ -171,31 +239,38 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader className="px-6 py-4">
|
<DialogHeader className="px-6 py-4">
|
||||||
<DialogTitle>QSO filter</DialogTitle>
|
<DialogTitle>{t('fltb.title')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-6 py-4 space-y-5">
|
<div className="px-6 py-4 space-y-5">
|
||||||
{/* Match mode + presets */}
|
{/* Match mode + presets */}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="text-muted-foreground">Match</span>
|
<span className="text-muted-foreground">{t('fltb.match')}</span>
|
||||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{(['AND', 'OR'] as const).map((m) => (
|
{(['AND', 'OR'] as const).map((m) => (
|
||||||
<button key={m} type="button" onClick={() => setMatch(m)}
|
<button key={m} type="button" onClick={() => setMatch(m)}
|
||||||
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
|
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
|
||||||
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
|
{m === 'AND' ? t('fltb.all') : t('fltb.any')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{presetNames.length > 0 && (
|
{presetNames.length > 0 && (
|
||||||
<Select onValueChange={loadPreset}>
|
<Select onValueChange={loadPreset}>
|
||||||
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
|
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder={t('fltb.loadPreset')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{presetNames.map((n) => (
|
{presetNames.map((n) => (
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
@@ -207,13 +282,15 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
|
||||||
{conditions.length === 0 && (
|
{conditions.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground py-4 text-center">No conditions — the list shows all QSOs. Add one below.</div>
|
<div className="text-xs text-muted-foreground py-4 text-center">{t('fltb.noConditions')}</div>
|
||||||
)}
|
)}
|
||||||
{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';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? t('fltb.where') : match}</span>
|
||||||
<Select value={c.field} onValueChange={(v) => {
|
<Select value={c.field} onValueChange={(v) => {
|
||||||
// Reset op if the new field type doesn't allow the current one.
|
// Reset op if the new field type doesn't allow the current one.
|
||||||
const allowed = opsFor(v).map((o) => o.value);
|
const allowed = opsFor(v).map((o) => o.value);
|
||||||
@@ -221,49 +298,71 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
|
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||||
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{t(o.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
|
// An ADIF date is picked with a calendar but STORED as the
|
||||||
|
// 8-digit form the column holds, so the comparison stays a
|
||||||
|
// plain string one on both sides.
|
||||||
|
// 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 ? 'value' : '—'}
|
placeholder={!needsValue ? '—'
|
||||||
value={c.value}
|
: isList ? t('fltb.listPh')
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
: 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}
|
||||||
|
onChange={(e) => setCond(i, {
|
||||||
|
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(); }}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
|
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
|
||||||
<Plus className="size-3.5 mr-1" /> Add condition
|
<Plus className="size-3.5 mr-1" /> {t('fltb.addCondition')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save preset */}
|
{/* Save preset */}
|
||||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||||
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
|
<Input className="h-8 w-56 text-xs" placeholder={t('fltb.presetNamePh')} value={presetName}
|
||||||
onChange={(e) => setPresetName(e.target.value)}
|
onChange={(e) => setPresetName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
|
||||||
<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" /> Save preset
|
<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>
|
||||||
|
|
||||||
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
|
||||||
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
|
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
|
||||||
<Button onClick={apply}>Apply & close</Button>
|
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
|||||||
// (no callsign configured yet). It writes straight into the active profile, so
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
const [p, setP] = useState<Profile | null>(null);
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -66,50 +68,66 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||||
|
{/* Language chooser — lives here (and not only in the localStorage-backed
|
||||||
|
first-launch flag gate) so a fresh setup always offers EN/FR, like the
|
||||||
|
station identity below. */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
|
||||||
|
<button key={code} type="button" onClick={() => setLang(code)}
|
||||||
|
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0 transition-colors',
|
||||||
|
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
<Flag className="w-5 rounded-[2px] border border-border/30" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Radio className="size-5 text-primary" />
|
<Radio className="size-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
{t('frm.intro')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||||
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
<Label className="text-sm">{t('frm.callsign')} <span className="text-destructive">*</span></Label>
|
||||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
<Label className="text-sm">{t('frm.locator')} <span className="text-destructive">*</span></Label>
|
||||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">Operator</Label>
|
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||||
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
<Input className="h-9 font-mono uppercase" placeholder={t('frm.operatorPh')} value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">Owner</Label>
|
<Label className="text-sm">{t('frm.owner')}</Label>
|
||||||
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
<Input className="h-9 font-mono uppercase" placeholder={t('frm.ownerPh')} value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">Name</Label>
|
<Label className="text-sm">{t('frm.name')}</Label>
|
||||||
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="font-medium">Award reference lists</div>
|
<div className="font-medium">{t('frm.awardRefs')}</div>
|
||||||
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||||
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-success' : 'text-destructive')}>{refsMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
{err && <div className="mt-3 text-xs text-destructive">{err}</div>}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||||
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,934 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetIcomState, IcomRefresh,
|
||||||
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
|
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
|
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||||
|
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||||||
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
|
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||||
|
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||||
|
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
|
||||||
|
type IcomState = {
|
||||||
|
available: boolean; model?: string; 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;
|
||||||
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||||||
|
agc?: string; preamp: number; att: number; filter: number;
|
||||||
|
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||||||
|
antenna: number;
|
||||||
|
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||||||
|
squelch: number; comp: boolean; comp_level: number;
|
||||||
|
monitor: boolean; mon_level: number;
|
||||||
|
vox: boolean; vox_gain: number; anti_vox: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: IcomState = {
|
||||||
|
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,
|
||||||
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||||||
|
preamp: 0, att: 0, filter: 1,
|
||||||
|
rit_hz: 0, rit_on: false, xit_on: false,
|
||||||
|
antenna: 1,
|
||||||
|
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||||||
|
squelch: 0, comp: false, comp_level: 0,
|
||||||
|
monitor: false, mon_level: 0,
|
||||||
|
vox: false, vox_gain: 0, anti_vox: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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: '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 },
|
||||||
|
];
|
||||||
|
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
|
||||||
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
|
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||||
|
|
||||||
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
|
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||||
|
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||||
|
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||||
|
function attOptions(model?: string): { v: string; l: string }[] {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
const OFF = { v: '0', l: 'OFF' };
|
||||||
|
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||||||
|
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
|
}
|
||||||
|
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||||
|
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||||||
|
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||||||
|
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||||||
|
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||||||
|
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||||||
|
function bandOfHz(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '';
|
||||||
|
const mhz = hz / 1_000_000;
|
||||||
|
const bands: [string, number, number][] = [
|
||||||
|
['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],
|
||||||
|
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||||
|
// 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;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||||
|
function modeMatches(btn: string, cur?: string): boolean {
|
||||||
|
if (!cur) return false;
|
||||||
|
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||||||
|
return btn === cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||||
|
// is ignored), so attach a non-passive native listener; read live values via
|
||||||
|
// refs to avoid stale closures.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const stepRef = useRef(step); stepRef.current = step;
|
||||||
|
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(0, Math.min(100, 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={0} max={100} 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')}
|
||||||
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, 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 }: { on: boolean; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick}
|
||||||
|
className={cn('w-14 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 LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||||||
|
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||||||
|
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (onClick) {
|
||||||
|
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||||||
|
}
|
||||||
|
return <div className="flex items-center gap-2">{body}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||||||
|
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||||||
|
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||||||
|
label: string; on: boolean; hz: number; accent: string;
|
||||||
|
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const cb = useRef(onDelta); cb.current = onDelta;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<div ref={ref} title="Wheel / ± to shift"
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||||
|
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||||
|
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClear}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||||
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
|
// the RST-tx value on click.
|
||||||
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
|
if (v >= 47) {
|
||||||
|
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||||||
|
// (near-black → blue → cyan → green → amber → red).
|
||||||
|
const WF_STOPS: [number, [number, number, number]][] = [
|
||||||
|
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||||||
|
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||||||
|
];
|
||||||
|
function wfColor(v: number): [number, number, number] {
|
||||||
|
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||||||
|
for (let i = 1; i < WF_STOPS.length; i++) {
|
||||||
|
if (v <= WF_STOPS[i][0]) {
|
||||||
|
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||||||
|
const f = (v - a) / (b - a || 1);
|
||||||
|
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||||||
|
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||||
|
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||||||
|
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||||||
|
function ScopePanadapter() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
const [fixed, setFixed] = useState(true);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||||
|
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||||
|
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||||||
|
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
||||||
|
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||||||
|
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !on;
|
||||||
|
setOn(next);
|
||||||
|
IcomSetScope(next).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
||||||
|
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
||||||
|
// just writes the rig's fixed edges — simple and independent of the waveform
|
||||||
|
// decode.
|
||||||
|
const SCOPE_HALF = 50_000;
|
||||||
|
const applyEdges = (center: number) => {
|
||||||
|
if (center <= 0) return;
|
||||||
|
centerRef.current = center;
|
||||||
|
setFixed(true);
|
||||||
|
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
||||||
|
};
|
||||||
|
const centerOnVfo = async () => {
|
||||||
|
let c = vfoRef.current;
|
||||||
|
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
||||||
|
applyEdges(c);
|
||||||
|
};
|
||||||
|
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
||||||
|
const setMode = (nextFixed: boolean) => {
|
||||||
|
setFixed(nextFixed);
|
||||||
|
IcomSetScopeMode(nextFixed).catch(() => {});
|
||||||
|
};
|
||||||
|
// Stop the stream when the panel unmounts.
|
||||||
|
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!on) return;
|
||||||
|
let raf = 0, lastSeq = -1, alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
const sw = await IcomScopeData();
|
||||||
|
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
||||||
|
lastSeq = sw.seq;
|
||||||
|
setFixed(sw.fixed);
|
||||||
|
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
|
||||||
|
let vfo = 0;
|
||||||
|
try {
|
||||||
|
const cs = await GetCATState();
|
||||||
|
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||||||
|
} catch {}
|
||||||
|
if (vfo > 0) vfoRef.current = vfo;
|
||||||
|
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [on]);
|
||||||
|
|
||||||
|
// Double-click tunes the rig to the clicked frequency.
|
||||||
|
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const { low, high } = spanRef.current;
|
||||||
|
if (!cv || !(low > 0 && high > low)) return;
|
||||||
|
const rect = cv.getBoundingClientRect();
|
||||||
|
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
|
||||||
|
SetCATFrequency(hz).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
|
||||||
|
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
|
||||||
|
// accumulate before the poll reconciles.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el || !on) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!vfoRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
|
||||||
|
vfoRef.current = next;
|
||||||
|
SetCATFrequency(next).catch(() => {});
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, [on]);
|
||||||
|
|
||||||
|
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = cv.clientWidth, h = cv.clientHeight;
|
||||||
|
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||||||
|
const peak = Math.max(...amp);
|
||||||
|
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||||||
|
const scale = peakRef.current;
|
||||||
|
const n = amp.length;
|
||||||
|
|
||||||
|
// Background — deep navy vertical gradient.
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Grid.
|
||||||
|
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||||
|
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||||
|
|
||||||
|
const xOf = (i: number) => (i / (n - 1)) * w;
|
||||||
|
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
||||||
|
|
||||||
|
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
||||||
|
const hold = holdRef.current;
|
||||||
|
if (hold.length !== n) hold.length = n, hold.fill(0);
|
||||||
|
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
||||||
|
|
||||||
|
// Filled spectrum area.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, h);
|
||||||
|
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||||||
|
ctx.lineTo(w, h); ctx.closePath();
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||||
|
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||||
|
ctx.fillStyle = grad; ctx.fill();
|
||||||
|
|
||||||
|
// Peak-hold trace (thin, faint).
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||||
|
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
|
||||||
|
// Live spectrum trace with a soft glow.
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||||
|
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
||||||
|
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
||||||
|
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
||||||
|
// you can tell which way to tune to get it back on-screen).
|
||||||
|
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
||||||
|
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
||||||
|
let markerX = -1;
|
||||||
|
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
||||||
|
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||||||
|
else if (!fixedMode) markerX = w / 2;
|
||||||
|
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
||||||
|
if (markerX >= 0) {
|
||||||
|
const x = markerX;
|
||||||
|
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
||||||
|
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
||||||
|
if (offEdge === 0) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
||||||
|
} else {
|
||||||
|
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||||||
|
// we label low/centre/high from them. In centre mode the header frame's edge
|
||||||
|
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||||||
|
// so we always label the centre with the live VFO frequency (which we fetch
|
||||||
|
// each sweep), and only add edge labels when the reported edges genuinely
|
||||||
|
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||||||
|
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||||
|
ctx.font = '10px ui-monospace, monospace';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
||||||
|
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
||||||
|
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
||||||
|
const validEdges = lowHz > 0 && highHz > lowHz;
|
||||||
|
if (fixedMode) {
|
||||||
|
if (validEdges) {
|
||||||
|
label(mhz(lowHz), 4, 'left');
|
||||||
|
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
||||||
|
label(mhz(highHz), w - 4, 'right');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
||||||
|
label(mhz(lowHz), 4, 'left');
|
||||||
|
label(mhz(highHz), w - 4, 'right');
|
||||||
|
}
|
||||||
|
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
||||||
|
}
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
drawWaterfall(amp, scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
||||||
|
// as a colour-mapped line at the top.
|
||||||
|
const drawWaterfall = (amp: number[], scale: number) => {
|
||||||
|
const cv = wfRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
||||||
|
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
// Scroll everything down by one pixel row.
|
||||||
|
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||||
|
// Paint the new top row.
|
||||||
|
const row = ctx.createImageData(w, 1);
|
||||||
|
const n = amp.length;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
||||||
|
const [r, g, b] = wfColor(amp[i] / scale);
|
||||||
|
const o = x * 4;
|
||||||
|
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(row, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapsible card: when the scope is off, only the header band shows (the
|
||||||
|
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
||||||
|
// and ON/OFF controls live in the header itself.
|
||||||
|
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">
|
||||||
|
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
|
{on && (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
||||||
|
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
||||||
|
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
||||||
|
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
||||||
|
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
||||||
|
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{on && (
|
||||||
|
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||||||
|
onChange={(v) => setMode(v === 'FIX')} />
|
||||||
|
)}
|
||||||
|
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{on && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||||
|
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||||
|
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||||
|
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
|
||||||
|
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||||||
|
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||||
|
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||||
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
|
const [tuning, setTuning] = useState(false);
|
||||||
|
const txRef = useRef(false);
|
||||||
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||||
|
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||||
|
};
|
||||||
|
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||||
|
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||||
|
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||||
|
const refresh = async () => {
|
||||||
|
try { await IcomRefresh(); } catch {}
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic local update + fire the command; the cache poll reconciles.
|
||||||
|
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||||||
|
setSt((s) => ({ ...s, ...patch }));
|
||||||
|
fn().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMox = () => {
|
||||||
|
const next = !txRef.current;
|
||||||
|
txRef.current = next;
|
||||||
|
set({ transmitting: next }, () => IcomSetPTT(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tune = async () => {
|
||||||
|
setTuning(true);
|
||||||
|
try { await IcomTune(); } catch {}
|
||||||
|
window.setTimeout(() => setTuning(false), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||||||
|
const setRit = (hz: number) => {
|
||||||
|
const v = Math.max(-9999, Math.min(9999, hz));
|
||||||
|
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||||||
|
// clarifier for zero-beating a caller without touching the mouse.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||||
|
const s = stRef.current;
|
||||||
|
if (!s.available || !s.rit_on) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
|
{t('icmp.notConnected')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = st.transmitting;
|
||||||
|
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||||||
|
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||||
|
const split = !!cat?.split;
|
||||||
|
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||||
|
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||||||
|
const curMode: string = cat?.mode || st.mode || '';
|
||||||
|
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||||
|
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||||
|
const um = curMode.toUpperCase();
|
||||||
|
const isCW = um === 'CW' || um === 'CWR';
|
||||||
|
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||||||
|
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||||||
|
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||||||
|
{tx ? 'TX' : 'RX'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||||||
|
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||||||
|
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||||
|
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||||||
|
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||||
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
|
{isNetwork && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
|
<Power className="size-3.5" /> ON
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||||
|
<Power className="size-3.5" /> OFF
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||||||
|
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||||||
|
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||||||
|
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||||||
|
{/* MAIN VFO */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||||||
|
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||||||
|
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||||||
|
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||||||
|
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||||||
|
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||||||
|
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||||||
|
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
|
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const on = modeMatches(m, curMode);
|
||||||
|
return (
|
||||||
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||||||
|
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||||||
|
{(() => { const sp = sParts(st.s_meter); return (
|
||||||
|
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||||||
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
|
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||||||
|
); })()}
|
||||||
|
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
||||||
|
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spectrum panadapter (full width). */}
|
||||||
|
<ScopePanadapter />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{/* Band buttons + antenna selection. */}
|
||||||
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
|
{bandsFor(st.model).map((b) => {
|
||||||
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
|
return (
|
||||||
|
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||||
|
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||||
|
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
here
|
||||||
|
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||||||
|
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||||||
|
{b.l}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Row label={t('icmp.antenna')}>
|
||||||
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
|
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
|
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transmit controls. */}
|
||||||
|
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||||
|
<Row label={t('icmp.power')}>
|
||||||
|
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||||||
|
</Row>
|
||||||
|
{isPhone && (
|
||||||
|
<Row label={t('icmp.mic')}>
|
||||||
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button type="button" onClick={toggleMox}
|
||||||
|
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||||||
|
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
|
{tx ? 'TX ON' : 'MOX'}
|
||||||
|
</button>
|
||||||
|
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||||
|
<button type="button" onClick={tune} disabled={tuning}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||||||
|
they don't exist on CW or data). */}
|
||||||
|
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||||||
|
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||||||
|
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||||||
|
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||||||
|
{isPhone && (
|
||||||
|
<>
|
||||||
|
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||||||
|
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||||||
|
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||||||
|
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||||||
|
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||||||
|
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||||||
|
<Row label="Anti-VOX">
|
||||||
|
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('icmp.squelch')}>
|
||||||
|
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||||
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label={t('icmp.preamp')}>
|
||||||
|
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||||
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Att">
|
||||||
|
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||||
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label={t('icmp.filter')}>
|
||||||
|
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||||
|
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||||||
|
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||||||
|
<Row label="PBT-IN">
|
||||||
|
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="PBT-OUT">
|
||||||
|
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||||||
|
</Row>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||||||
|
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
{t('icmp.pbtCenter')}
|
||||||
|
</button>
|
||||||
|
<div className="pt-1 border-t border-border/60 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||||||
|
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||||||
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||||
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||||
|
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||||
|
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||||
|
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||||
|
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||||
|
</div>
|
||||||
|
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||||||
|
{isCW && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,28 +43,71 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
|
export function addBasemap(
|
||||||
|
m: L.Map,
|
||||||
|
key: BasemapKey,
|
||||||
|
base: 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 (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
|
const bm = BASEMAPS[key];
|
||||||
|
const tileOpts: L.TileLayerOptions = {
|
||||||
|
maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
|
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 {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -81,10 +125,11 @@ interface WorldProps {
|
|||||||
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||||
beamWidth?: number; // beamwidth (deg), default 30
|
beamWidth?: number; // beamwidth (deg), default 30
|
||||||
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||||
|
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
|
||||||
const worldRef = useRef<HTMLDivElement>(null);
|
const worldRef = useRef<HTMLDivElement>(null);
|
||||||
const worldMap = useRef<L.Map | null>(null);
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
@@ -96,17 +141,26 @@ 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]);
|
||||||
|
|
||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||||
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
|
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||||
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
|
// patches. On canvas it is a single layer.
|
||||||
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
const bm = BASEMAPS[basemap];
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
@@ -114,7 +168,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
return () => window.clearTimeout(t);
|
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||||
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
|
// the rotor moved a degree.
|
||||||
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
@@ -122,14 +181,83 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!m) return;
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
|
||||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
|
||||||
const bm = BASEMAPS[basemap];
|
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
}, [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;
|
||||||
@@ -139,18 +267,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
const from = gridToLatLon(fromGrid);
|
const from = gridToLatLon(fromGrid);
|
||||||
const to = gridToLatLon(toGrid);
|
const to = gridToLatLon(toGrid);
|
||||||
|
|
||||||
if (from && to) {
|
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||||||
|
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||||||
|
// the beam heading should show even before a callsign is entered.
|
||||||
|
if (from) {
|
||||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
.addTo(wo);
|
.addTo(wo);
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
|
||||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
|
||||||
.addTo(wo);
|
|
||||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
|
||||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
|
||||||
// line, which makes a smooth arc look angular/bumpy).
|
|
||||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
|
||||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
|
||||||
|
|
||||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||||
if (beamAzimuths && beamAzimuths.length) {
|
if (beamAzimuths && beamAzimuths.length) {
|
||||||
@@ -169,21 +292,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
for (const az of beamAzimuths) {
|
for (const az of beamAzimuths) {
|
||||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||||
// filled polygon: a polygon breaks badly near the poles on Mercator
|
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||||
// (its edges run off toward ±90° and the fill smears across the map),
|
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||||
// while each radial LINE stays clean. The overlapping lines read as a
|
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||||
// lobe — solid near the antenna, fanning out toward the front. Works
|
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||||
// for any azimuth, north/south included.
|
// the antenna and fanning out toward the front.
|
||||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
// Dark casing under the boresight so the bright dashed line stays
|
// Dark casing under the boresight so the bright dashed line stays
|
||||||
// readable on any basemap (esp. dark satellite imagery). Same dashArray
|
// readable on any basemap. Same dashArray so the casing tracks each dash.
|
||||||
// as the red line so the casing tracks each dash — otherwise the wide
|
|
||||||
// casing peeks through the gaps and the line looks bumpy.
|
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
@@ -204,20 +326,39 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||||
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||||||
|
let arcPts: [number, number][] | null = null;
|
||||||
|
if (from && to) {
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||||
|
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||||||
|
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||||||
|
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||||
|
}
|
||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
|
if (from && to && arcPts) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
}
|
} else if (to) {
|
||||||
} else if (autoZoom && to) {
|
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
} else if (autoZoom && from) {
|
} else if (from) {
|
||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
}
|
||||||
|
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||||
|
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||||
|
// thrash the compositor.
|
||||||
|
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||||
|
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||||
|
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
@@ -225,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}
|
||||||
@@ -258,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
|
||||||
@@ -285,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type MenuItem =
|
export type MenuItem =
|
||||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||||
|
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||||
|
// moment the menu is translated.
|
||||||
|
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||||
| { type: 'separator' };
|
| { type: 'separator' };
|
||||||
|
|
||||||
export interface Menu {
|
export interface Menu {
|
||||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={i}
|
key={i}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||||
onSelect={() => onAction(item.action)}
|
onSelect={() => onAction(item.action)}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||||
|
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||||
|
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||||
|
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||||
|
export const METER_SEGMENTS = 26;
|
||||||
|
|
||||||
|
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} title={title}
|
||||||
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AllCommunityModule, ModuleRegistry,
|
||||||
|
type ColDef,
|
||||||
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import type { QSOForm } from '@/types';
|
||||||
|
import {
|
||||||
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
WorkedBefore,
|
||||||
|
} from '@/../wailsjs/go/main/App';
|
||||||
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
const hamlogTheme = hamlogGridTheme;
|
||||||
|
|
||||||
|
type Net = netctl.Net;
|
||||||
|
type Station = netctl.Station;
|
||||||
|
|
||||||
|
function fmtTimeOn(s: any): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onLogged?: () => void;
|
||||||
|
countries?: string[];
|
||||||
|
bands?: string[];
|
||||||
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
|
const [selId, setSelId] = useState<string>('');
|
||||||
|
const [openId, setOpenId] = useState<string>('');
|
||||||
|
const [roster, setRoster] = useState<Station[]>([]);
|
||||||
|
const [active, setActive] = useState<QSOForm[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||||
|
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||||
|
|
||||||
|
// Add/edit-contact dialog.
|
||||||
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
|
const [wbCall, setWbCall] = useState('');
|
||||||
|
const [wb, setWb] = useState<any>(null);
|
||||||
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
const wbReq = useRef(0);
|
||||||
|
// Resizable height (drag the handle on top). Persisted.
|
||||||
|
const [wbHeight, setWbHeight] = useState(() => {
|
||||||
|
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
||||||
|
return v >= 80 && v <= 600 ? v : 176;
|
||||||
|
});
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
||||||
|
const startWbResize = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startY = e.clientY, startH = wbHeight;
|
||||||
|
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
||||||
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
||||||
|
const c = (call ?? '').trim().toUpperCase();
|
||||||
|
setWbCall(c);
|
||||||
|
if (!c) { setWb(null); return; }
|
||||||
|
const req = ++wbReq.current;
|
||||||
|
setWbBusy(true);
|
||||||
|
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
||||||
|
catch { if (wbReq.current === req) setWb(null); }
|
||||||
|
finally { if (wbReq.current === req) setWbBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
|
const refreshNets = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
||||||
|
const arr = (list ?? []) as Net[];
|
||||||
|
setNets(arr);
|
||||||
|
setOpenId(oid ?? '');
|
||||||
|
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshRoster = useCallback(async (id: string) => {
|
||||||
|
if (!id) { setRoster([]); return; }
|
||||||
|
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshActive = useCallback(async () => {
|
||||||
|
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Mic pass order ------------------------------------------------------
|
||||||
|
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||||
|
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||||
|
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||||
|
// buttons move the selected station. Persisted per net so a panel remount
|
||||||
|
// (tab switch) keeps the round.
|
||||||
|
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||||
|
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||||
|
// Load the saved order when a net opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderKey) { setOrderIds([]); return; }
|
||||||
|
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||||
|
catch { setOrderIds([]); }
|
||||||
|
}, [orderKey]);
|
||||||
|
// Reconcile with the live list: keep known ids in their order, append new
|
||||||
|
// ones, drop the gone. Persist.
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||||
|
const liveSet = new Set(liveIds);
|
||||||
|
const kept = cur.filter((id) => liveSet.has(id));
|
||||||
|
const keptSet = new Set(kept);
|
||||||
|
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||||
|
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [active, orderKey]);
|
||||||
|
const activeOrdered = useMemo(() => {
|
||||||
|
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||||
|
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||||
|
}, [active, orderIds]);
|
||||||
|
// Move the selected on-air station up/down the queue.
|
||||||
|
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||||
|
const id = selectedActiveIds[0];
|
||||||
|
if (id == null) return;
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const i = cur.indexOf(id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||||
|
const next = [...cur];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [selectedActiveIds, orderKey]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||||
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
|
|
||||||
|
// The roster side hides callsigns currently on the air (they live in the left
|
||||||
|
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||||
|
const activeCalls = useMemo(
|
||||||
|
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||||
|
[active],
|
||||||
|
);
|
||||||
|
const rosterShown = useMemo(
|
||||||
|
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||||
|
[roster, activeCalls],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function newNet() {
|
||||||
|
const name = window.prompt(t('ncp.newNetPrompt'));
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function renameNet() {
|
||||||
|
if (!selId) return;
|
||||||
|
const cur = nets.find((n) => n.id === selId);
|
||||||
|
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function deleteNet() {
|
||||||
|
if (!selId) return;
|
||||||
|
const cur = nets.find((n) => n.id === selId);
|
||||||
|
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
|
||||||
|
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function toggleOpen() {
|
||||||
|
try {
|
||||||
|
if (isOpen) {
|
||||||
|
if (active.length > 0 &&
|
||||||
|
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
|
||||||
|
await NetClose(); setOpenId('');
|
||||||
|
} else {
|
||||||
|
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster → active (start QSO).
|
||||||
|
async function activate(call: string) {
|
||||||
|
if (!isOpen || !call) return;
|
||||||
|
try { await NetActivate(call); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||||
|
// can chain "log → log → log" without re-clicking a row each time.
|
||||||
|
async function deactivate(id?: number) {
|
||||||
|
if (id == null) return;
|
||||||
|
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
|
try {
|
||||||
|
await NetDeactivate(id);
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
if (next?.id != null) {
|
||||||
|
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||||
|
setSelectedActiveIds([next.id as number]);
|
||||||
|
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||||
|
activateRef.current = activate;
|
||||||
|
deactivateRef.current = deactivate;
|
||||||
|
|
||||||
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
|
// exactly as the single-log button would.
|
||||||
|
async function logAll() {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const a of [...active]) {
|
||||||
|
if (a.id != null) await NetDeactivate(a.id as number);
|
||||||
|
}
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
|
async function saveDraft(q: QSOForm) {
|
||||||
|
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function discardDraft(id: number) {
|
||||||
|
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add-contact dialog.
|
||||||
|
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
||||||
|
async function lookupContact() {
|
||||||
|
const call = (contact.callsign ?? '').trim();
|
||||||
|
if (!call) return;
|
||||||
|
setLooking(true);
|
||||||
|
try {
|
||||||
|
const r = await NetLookup(call);
|
||||||
|
setContact((c) => netctl.Station.createFrom({
|
||||||
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
|
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||||
|
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||||
|
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
||||||
|
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
||||||
|
email: r.email || c.email,
|
||||||
|
}));
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setLooking(false); }
|
||||||
|
}
|
||||||
|
async function saveContact() {
|
||||||
|
const call = (contact.callsign ?? '').trim().toUpperCase();
|
||||||
|
if (!call || !selId) return;
|
||||||
|
try {
|
||||||
|
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
||||||
|
setContactOpen(false);
|
||||||
|
await refreshRoster(selId);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function removeSelectedRoster() {
|
||||||
|
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||||
|
if (sel.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||||
|
await refreshRoster(selId);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||||
|
<Plus className="size-3.5" /> {t('ncp.newNet')}
|
||||||
|
</Button>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||||
|
value={selId}
|
||||||
|
disabled={isOpen}
|
||||||
|
onChange={(e) => setSelId(e.target.value)}
|
||||||
|
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
|
||||||
|
>
|
||||||
|
<option value="">{t('ncp.selectNetOption')}</option>
|
||||||
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||||
|
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||||
|
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||||
|
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||||
|
</span>
|
||||||
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* ACTIVE USERS (left) */}
|
||||||
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
{t('ncp.onAirActive')}
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
|
<RecentQSOsGrid
|
||||||
|
rows={activeOrdered}
|
||||||
|
total={activeOrdered.length}
|
||||||
|
storageKey="net.onair"
|
||||||
|
selectRowSignal={selectRow}
|
||||||
|
rowDragCall
|
||||||
|
passOrder
|
||||||
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
|
onRowSelected={setSelectedActiveIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOpen && active.length > 0 && (
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
|
</Button>
|
||||||
|
{/* Mic pass order: move the selected station up/down the round. */}
|
||||||
|
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||||
|
onClick={() => moveSelected(-1)}>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||||
|
onClick={() => moveSelected(1)}>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Worked-before for the clicked station — click a row (on air or
|
||||||
|
roster) to see if/when we contacted it before. Lives INSIDE the
|
||||||
|
on-air column so resizing only splits this column; the roster on
|
||||||
|
the right keeps its full height. */}
|
||||||
|
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
||||||
|
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
||||||
|
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
|
<History className="size-3.5" />
|
||||||
|
{t('ncp.workedBefore')}
|
||||||
|
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
||||||
|
{wb && wb.count > 0 && (
|
||||||
|
<span className="ml-auto font-normal normal-case text-foreground">
|
||||||
|
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
||||||
|
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
{!wbCall ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
|
) : wbBusy ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
||||||
|
) : !wb || wb.count === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||||
|
) : (
|
||||||
|
<RecentQSOsGrid
|
||||||
|
rows={(wb.entries ?? []) as any}
|
||||||
|
total={wb.count}
|
||||||
|
storageKey="net.wb"
|
||||||
|
{...qsoMenuHandlers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NET USERS / roster (right) */}
|
||||||
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
{t('ncp.netUsersRoster')}
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
<AgGridReact<Station>
|
||||||
|
ref={rosterGrid}
|
||||||
|
theme={hamlogTheme}
|
||||||
|
rowData={rosterShown}
|
||||||
|
columnDefs={rosterCols}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
|
animateRows={false}
|
||||||
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||||
|
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||||
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||||
|
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
||||||
|
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
||||||
|
{editingDraft && (
|
||||||
|
<QSOEditModal
|
||||||
|
qso={editingDraft}
|
||||||
|
onSave={saveDraft}
|
||||||
|
onDelete={discardDraft}
|
||||||
|
onClose={() => setEditingDraft(null)}
|
||||||
|
countries={countries}
|
||||||
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / edit contact dialog */}
|
||||||
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 px-5 py-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
|
||||||
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||||
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||||
|
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">{t('ncp.name')}</Label>
|
||||||
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">QTH</Label>
|
||||||
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">{t('ncp.country')}</Label>
|
||||||
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
|
||||||
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||||
|
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type Band = { band: string; is_default: boolean };
|
type Band = { band: string; is_default: boolean };
|
||||||
type Antenna = {
|
type Antenna = {
|
||||||
@@ -38,6 +39,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function OperatingPanel({ bands, onError }: Props) {
|
export function OperatingPanel({ bands, onError }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [tree, setTree] = useState<Station[]>([]);
|
const [tree, setTree] = useState<Station[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// expanded keeps which stations show their antennas; everything open by
|
// expanded keeps which stations show their antennas; everything open by
|
||||||
@@ -131,7 +133,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
|||||||
} catch (e: any) { onError(String(e?.message ?? e)); }
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function removeAntenna(stationId: number, antId: number) {
|
async function removeAntenna(stationId: number, antId: number) {
|
||||||
if (!confirm('Delete this antenna?')) return;
|
if (!confirm(t('op.deleteAntConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await DeleteOperatingAntenna(antId);
|
await DeleteOperatingAntenna(antId);
|
||||||
setTree((prev) => prev.map((s) =>
|
setTree((prev) => prev.map((s) =>
|
||||||
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
Define your rigs (stations) and the antennas connected to each one.
|
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||||
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
|
|
||||||
the default antenna for that band — when you change the band in the
|
|
||||||
entry strip, the matching rig + antenna auto-fill the MY_RIG and
|
|
||||||
MY_ANTENNA ADIF fields. Only one antenna can be the default per
|
|
||||||
band; setting one clears the previous default.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={addStation}>
|
<Button size="sm" onClick={addStation}>
|
||||||
<Plus className="size-3.5" /> Add rig
|
<Plus className="size-3.5" /> {t('op.addRig')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tree.length === 0 ? (
|
{tree.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
|
||||||
No rig configured yet. Click "Add rig" to get started.
|
{t('op.noRig')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -212,6 +209,7 @@ function StationRow({
|
|||||||
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
|
||||||
onUpdateAntenna, onDeleteAntenna,
|
onUpdateAntenna, onDeleteAntenna,
|
||||||
}: StationRowProps) {
|
}: StationRowProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const editKey = `station:${station.id}`;
|
const editKey = `station:${station.id}`;
|
||||||
const isEditing = editing === editKey;
|
const isEditing = editing === editKey;
|
||||||
const [draft, setDraft] = useState({
|
const [draft, setDraft] = useState({
|
||||||
@@ -242,7 +240,7 @@ function StationRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
className="p-0.5 hover:bg-accent/40 rounded"
|
className="p-0.5 hover:bg-accent/40 rounded"
|
||||||
title={expanded ? 'Collapse' : 'Expand'}
|
title={expanded ? t('op.collapse') : t('op.expand')}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -253,12 +251,12 @@ function StationRow({
|
|||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 text-sm flex-1"
|
className="h-7 text-sm flex-1"
|
||||||
placeholder="Rig name (also stamped as MY_RIG)"
|
placeholder={t('op.rigNamePh')}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
|
<Label className="text-[11px] text-muted-foreground">{t('op.powerW')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -269,8 +267,8 @@ function StationRow({
|
|||||||
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={commit}>Save</Button>
|
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -279,13 +277,13 @@ function StationRow({
|
|||||||
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
|
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title={t('op.edit')}>
|
||||||
<Edit2 className="size-3" />
|
<Edit2 className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
|
||||||
<Plus className="size-3" /> Antenna
|
<Plus className="size-3" /> {t('op.antenna')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
|
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title={t('op.deleteRig')}>
|
||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -296,7 +294,7 @@ function StationRow({
|
|||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{(station.antennas ?? []).length === 0 ? (
|
{(station.antennas ?? []).length === 0 ? (
|
||||||
<div className="text-[11px] text-muted-foreground italic pl-6">
|
<div className="text-[11px] text-muted-foreground italic pl-6">
|
||||||
No antenna yet — click "Antenna" above to add one.
|
{t('op.noAntenna')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(station.antennas ?? []).map((a) => (
|
(station.antennas ?? []).map((a) => (
|
||||||
@@ -329,6 +327,7 @@ type AntennaRowProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const editKey = `antenna:${antenna.id}`;
|
const editKey = `antenna:${antenna.id}`;
|
||||||
const isEditing = editing === editKey;
|
const isEditing = editing === editKey;
|
||||||
const [draft, setDraft] = useState({ name: antenna.name });
|
const [draft, setDraft] = useState({ name: antenna.name });
|
||||||
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 text-sm flex-1"
|
className="h-7 text-sm flex-1"
|
||||||
placeholder="Antenna name (also stamped as MY_ANTENNA)"
|
placeholder={t('op.antNamePh')}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={commitNames}>Save</Button>
|
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -412,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
? 'border-warning-border bg-warning-muted shadow-sm'
|
||||||
: enabled
|
: enabled
|
||||||
? 'border-primary/30 bg-primary/5'
|
? 'border-primary/30 bg-primary/5'
|
||||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||||
@@ -431,14 +430,14 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'bg-amber-400 text-white'
|
? 'bg-warning text-warning-foreground'
|
||||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-warning hover:text-warning',
|
||||||
)}
|
)}
|
||||||
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
|
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
||||||
>
|
>
|
||||||
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
<Star className={cn('size-3', isDefault && 'fill-current')} />
|
||||||
<span className="text-[9px] font-bold uppercase tracking-wider">
|
<span className="text-[9px] font-bold uppercase tracking-wider">
|
||||||
{isDefault ? 'Default' : 'Set'}
|
{isDefault ? t('op.default') : t('op.set')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,41 +1,77 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||||
|
import { AllCommunityModule, ModuleRegistry, type ColDef } from 'ag-grid-community';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
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 {
|
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 } 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 { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: string;
|
band: string; mode: string; country: string; status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||||
|
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
|
||||||
|
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
|
||||||
|
{ field: 'band', headerName: 'Band', width: 90 },
|
||||||
|
{ field: 'mode', headerName: 'Mode', width: 100 },
|
||||||
|
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
|
||||||
|
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
|
{ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
{ v: '_', label: '— leave —' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Y', label: 'Yes' },
|
{ v: 'Y', label: 'qslm.yes' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'qslm.no' },
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'qslm.requested' },
|
||||||
{ v: 'I', label: 'Ignore' },
|
{ v: 'I', label: 'qslm.ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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 = [
|
||||||
|
{ v: '_', label: 'qslm.leave' },
|
||||||
|
{ v: 'B', label: 'qslm.viaBureau' },
|
||||||
|
{ v: 'D', label: 'qslm.viaDirect' },
|
||||||
|
{ v: 'E', label: 'qslm.viaElectronic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maps a service value → its i18n label key (only for services with
|
||||||
|
// translatable names; brand names like QRZ.com stay as-is in SERVICES).
|
||||||
|
const SERVICE_LABEL_KEYS: Record<string, string> = {
|
||||||
|
pota: 'qslm.svcPota',
|
||||||
|
paper: 'qslm.svcPaper',
|
||||||
|
};
|
||||||
|
|
||||||
type LogQSO = {
|
type LogQSO = {
|
||||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||||
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||||
@@ -44,13 +80,14 @@ type LogQSO = {
|
|||||||
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
||||||
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
|
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
|
||||||
|
|
||||||
|
// label holds an i18n key.
|
||||||
const SENT_STATUSES = [
|
const SENT_STATUSES = [
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'qslm.sentRequested' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'qslm.sentNo' },
|
||||||
{ v: 'Q', label: 'Queued' },
|
{ v: 'Q', label: 'qslm.sentQueued' },
|
||||||
{ v: 'Y', label: 'Yes (already sent)' },
|
{ v: 'Y', label: 'qslm.sentYes' },
|
||||||
{ v: 'I', label: 'Invalid' },
|
{ v: 'I', label: 'qslm.sentInvalid' },
|
||||||
{ v: '_', label: '— blank —' },
|
{ v: '_', label: 'qslm.sentBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function fmtDate(iso: string): string {
|
function fmtDate(iso: string): string {
|
||||||
@@ -75,7 +112,39 @@ 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 [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
|
||||||
// service (Force station callsign, else the profile call). Shown so the user
|
// service (Force station callsign, else the profile call). Shown so the user
|
||||||
@@ -85,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('');
|
||||||
@@ -103,13 +176,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [paperCall, setPaperCall] = useState('');
|
const [paperCall, setPaperCall] = useState('');
|
||||||
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||||
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||||
|
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
|
||||||
const [paperBusy, setPaperBusy] = useState(false);
|
const [paperBusy, setPaperBusy] = useState(false);
|
||||||
const [paperMsg, setPaperMsg] = useState('');
|
const [paperMsg, setPaperMsg] = useState('');
|
||||||
const [qslRcvd, setQslRcvd] = useState('Y');
|
const [qslRcvd, setQslRcvd] = useState('N');
|
||||||
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 [qslComment, setQslComment] = useState('');
|
||||||
|
|
||||||
const searchPaper = useCallback(async () => {
|
const searchPaper = useCallback(async () => {
|
||||||
const c = paperCall.trim().toUpperCase();
|
const c = paperCall.trim().toUpperCase();
|
||||||
@@ -120,14 +197,33 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const list = (r ?? []) as LogQSO[];
|
const list = (r ?? []) as LogQSO[];
|
||||||
setPaperRows(list);
|
setPaperRows(list);
|
||||||
setPaperSel(new Set(list.map((x) => x.id)));
|
setPaperSel(new Set(list.map((x) => x.id)));
|
||||||
|
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
|
||||||
|
|
||||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
||||||
finally { setPaperBusy(false); }
|
finally { setPaperBusy(false); }
|
||||||
}, [paperCall]);
|
}, [paperCall]);
|
||||||
|
|
||||||
function togglePaper(id: number) {
|
|
||||||
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
// Honour an incoming request: switch to Paper QSL, fill the callsign, search.
|
||||||
}
|
// The search runs from the effect below once paperCall has actually changed —
|
||||||
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
|
// 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);
|
||||||
@@ -140,16 +236,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||||
sent_date: ymd(qslSentDate),
|
sent_date: ymd(qslSentDate),
|
||||||
rcvd_date: ymd(qslRcvdDate),
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
via: qslVia,
|
via: qslVia === '_' ? '' : qslVia,
|
||||||
|
rcvd_via: qslRcvdVia === '_' ? '' : qslRcvdVia,
|
||||||
|
notes: qslNotes,
|
||||||
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
setPaperMsg(`${n} QSO updated.`);
|
setPaperMsg(t('qslm.qsoUpdated', { n }));
|
||||||
await searchPaper();
|
await searchPaper();
|
||||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||||
finally { setPaperBusy(false); }
|
finally { setPaperBusy(false); }
|
||||||
}
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [selectedCount, setSelectedCount] = useState(0);
|
||||||
|
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
|
||||||
|
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
@@ -167,28 +269,42 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||||
|
|
||||||
|
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||||
|
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||||
|
const [stats, setStats] = useState<any>(
|
||||||
|
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
|
||||||
|
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
refreshCounts(); // a download may have added confirmations
|
||||||
});
|
});
|
||||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||||
setConfirmations((list ?? []) as Confirmation[]);
|
setConfirmations((list ?? []) as Confirmation[]);
|
||||||
setViewMode('confirmations');
|
setViewMode('confirmations');
|
||||||
|
refreshCounts();
|
||||||
});
|
});
|
||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const selectedCount = selected.size;
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
|
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||||
|
function onUploadRowSelected(ids: number[]) {
|
||||||
|
setUploadSelIds(ids);
|
||||||
|
setSelectedCount(ids.length);
|
||||||
|
}
|
||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||||
case 'dxcc': return c.new_dxcc;
|
case 'dxcc': return c.new_dxcc;
|
||||||
case 'band': return c.new_band;
|
case 'band': return c.new_band;
|
||||||
|
case 'mode': return c.new_mode;
|
||||||
case 'slot': return c.new_slot;
|
case 'slot': return c.new_slot;
|
||||||
default: return true;
|
default: return true;
|
||||||
}
|
}
|
||||||
@@ -199,33 +315,24 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
const list = (r ?? []) as UploadRow[];
|
const list = (r ?? []) as any[];
|
||||||
setRows(list);
|
setRows(list);
|
||||||
setSelected(new Set(list.map((x) => x.id)));
|
setUploadSelIds(list.map((x) => x.id));
|
||||||
|
setSelectedCount(list.length);
|
||||||
|
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
|
||||||
setViewMode('upload');
|
setViewMode('upload');
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setSelected(new Set());
|
setSelectedCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}, [service, sent]);
|
}, [service, sent]);
|
||||||
|
|
||||||
function toggle(id: number) {
|
|
||||||
setSelected((s) => {
|
|
||||||
const n = new Set(s);
|
|
||||||
if (n.has(id)) n.delete(id); else n.add(id);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleAll() {
|
|
||||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
const ids = uploadSelIds;
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
try { await UploadQSOsManual(service, ids); }
|
try { await UploadQSOsManual(service, ids); }
|
||||||
@@ -243,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();
|
||||||
@@ -253,18 +388,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{/* Search toolbar */}
|
{/* Search toolbar */}
|
||||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
|
||||||
<Select value={service} onValueChange={setService}>
|
<Select value={service} onValueChange={setService}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{uploadCall && (
|
{uploadCall && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||||
<span
|
<span
|
||||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||||
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
title={t('qslm.callsignScopeTitle')}
|
||||||
>
|
>
|
||||||
{uploadCall}
|
{uploadCall}
|
||||||
</span>
|
</span>
|
||||||
@@ -274,142 +409,149 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<>
|
<>
|
||||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||||
Sync hunter log
|
{t('qslm.syncHunterLog')}
|
||||||
</Button>
|
</Button>
|
||||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
|
||||||
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||||
Only my profile callsign
|
{t('qslm.onlyMyCall')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
|
||||||
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||||
Add not-found QSOs to my log
|
{t('qslm.addMissing')}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
|
||||||
</>
|
</>
|
||||||
) : service === 'paper' ? (
|
) : service === 'paper' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
|
||||||
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||||
onChange={(e) => setPaperCall(e.target.value)}
|
onChange={(e) => setPaperCall(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||||
placeholder="e.g. DL1ABC" />
|
placeholder={t('qslm.callsignPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
||||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
Search
|
{t('qslm.search')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
|
||||||
<Select value={sent} onValueChange={setSent}>
|
<Select value={sent} onValueChange={setSent}>
|
||||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
Select required
|
{t('qslm.selectRequired')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
|
||||||
|
per-class breakdown so the totals are checkable. */}
|
||||||
|
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||||
|
<span className="font-semibold">{stats.slots_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||||
|
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
|
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{service === 'paper' && paperRows.length > 0 && (
|
{service === 'paper' && paperRows.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
|
||||||
)}
|
)}
|
||||||
{!showLog && viewMode === 'confirmations' && (
|
{!showLog && viewMode === 'confirmations' && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
|
||||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
|
||||||
<SelectItem value="new">New (any)</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">New band</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
<SelectItem value="slot">New slot</SelectItem>
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{logLines.length > 0 && (
|
{logLines.length > 0 && (
|
||||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||||
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{viewMode === 'confirmations'
|
{viewMode === 'confirmations'
|
||||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
|
||||||
: `${rows.length} found · ${selectedCount} selected`}
|
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content: log OR results grid */}
|
{/* Content: log OR results grid */}
|
||||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
{error && <div className="text-xs text-danger mb-2">{error}</div>}
|
||||||
|
|
||||||
{service === 'paper' ? (
|
{service === 'paper' ? (
|
||||||
paperRows.length === 0 ? (
|
paperRows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||||
<thead className="sticky top-0 bg-card">
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
|
<RecentQSOsGrid
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
// Its OWN column layout. Without a storageKey this fell back to
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
// the main log's key, so every resize or hidden column here
|
||||||
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||||
</tr>
|
storageKey="qslmgr.paper"
|
||||||
</thead>
|
rows={paperRows as any}
|
||||||
<tbody>
|
total={paperRows.length}
|
||||||
{paperRows.map((r) => (
|
selectAllSignal={paperSelAllSig}
|
||||||
<tr key={r.id}
|
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
{...actions}
|
||||||
onClick={() => togglePaper(r.id)}>
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
|
/>
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
</div>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
||||||
<td className="py-1 px-2">{r.band}</td>
|
|
||||||
<td className="py-1 px-2">{r.mode}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
|
||||||
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)
|
)
|
||||||
) : service === 'pota' ? (
|
) : service === 'pota' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
||||||
{!potaRes && !potaErr && !potaSyncing && (
|
{!potaRes && !potaErr && !potaSyncing && (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
|
||||||
)}
|
)}
|
||||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app…</div>}
|
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
||||||
{potaRes && (
|
{potaRes && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||||
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
|
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||||
{potaRes.skipped_other_call > 0 && (
|
{potaRes.skipped_other_call > 0 && (
|
||||||
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
|
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||||
)}
|
)}
|
||||||
{' '}Rescan the POTA award to count the new references.
|
{' '}{t('qslm.potaRescan')}
|
||||||
</div>
|
</div>
|
||||||
{potaRes.unmatched_list?.length > 0 && (
|
{potaRes.unmatched_list?.length > 0 && (
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<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 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
|
||||||
<th className="py-1.5 px-2">Why unmatched</th>
|
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -417,7 +559,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<tr key={i}
|
<tr key={i}
|
||||||
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
|
||||||
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
|
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
|
||||||
title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}>
|
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
||||||
<td className="py-1 px-2 font-mono">{u.date}</td>
|
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||||
<td className="py-1 px-2">{u.band}</td>
|
<td className="py-1 px-2">{u.band}</td>
|
||||||
@@ -437,27 +579,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : showLog ? (
|
) : showLog ? (
|
||||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||||
{logLines.length === 0 ? (
|
{logLines.length === 0 ? (
|
||||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
||||||
) : logLines.map((l, i) => (
|
) : logLines.map((l, i) => (
|
||||||
<div key={i} className={cn(
|
<div key={i} className={cn(
|
||||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
/FAIL|failed|error/i.test(l) ? 'text-danger'
|
||||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
|
||||||
: 'text-foreground/90')}>{l}</div>
|
: 'text-foreground/90')}>{l}</div>
|
||||||
))}
|
))}
|
||||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'confirmations' ? (
|
) : viewMode === 'confirmations' ? (
|
||||||
shownConfs.length === 0 ? (
|
shownConfs.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<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 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
|
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -469,9 +611,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
<td className="py-1 px-2">
|
<td className="py-1 px-2">
|
||||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||||
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||||
: <span className="text-muted-foreground/50">—</span>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -480,69 +623,69 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<thead className="sticky top-0 bg-card">
|
<RecentQSOsGrid
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
storageKey="qslmgr.upload"
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
rows={rows as any}
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
total={rows.length}
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
selectAllSignal={uploadSelAllSig}
|
||||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
onRowSelected={onUploadRowSelected}
|
||||||
</tr>
|
{...actions}
|
||||||
</thead>
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
<tbody>
|
/>
|
||||||
{rows.map((r) => (
|
</div>
|
||||||
<tr key={r.id}
|
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
|
||||||
onClick={() => toggle(r.id)}>
|
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
|
||||||
</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
||||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
||||||
<td className="py-1 px-2">{r.band}</td>
|
|
||||||
<td className="py-1 px-2">{r.mode}</td>
|
|
||||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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">QSL received</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">
|
||||||
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<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>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
|
||||||
</div>
|
</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">QSL sent</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Select value={qslSent} onValueChange={setQslSent}>
|
<Select value={qslSent} onValueChange={setQslSent}>
|
||||||
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||||
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.sentViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
|
||||||
</div>
|
</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">Via</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||||
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
|
||||||
|
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||||
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||||
Apply to {paperSel.size} selected
|
{t('qslm.applyToSelected', { n: paperSel.size })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -551,19 +694,33 @@ 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">
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
{service === 'hamlog' ? (
|
||||||
title="Fetch confirmations from the service and update received status">
|
<>
|
||||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||||
</Button>
|
</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}
|
||||||
|
title={t('qslm.downloadTitle')}>
|
||||||
|
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||||
|
</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="How far back to download">
|
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="last">Since last download</SelectItem>
|
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
|
||||||
<SelectItem value="date">Since date…</SelectItem>
|
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{sinceMode === 'date' && (
|
{sinceMode === 'date' && (
|
||||||
@@ -572,16 +729,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
value={sinceDate}
|
value={sinceDate}
|
||||||
onChange={(e) => setSinceDate(e.target.value)}
|
onChange={(e) => setSinceDate(e.target.value)}
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
|
title={service === 'qrz' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
|
||||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
Add not-found
|
{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" /> Upload {selectedCount} to {serviceLabel}
|
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,83 +1,146 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
||||||
|
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;
|
||||||
|
|
||||||
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;
|
||||||
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;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportFilteredFull?: () => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloFiltered?: () => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
{ service: 'qrz', name: 'QRZ.com' },
|
||||||
{ service: 'clublog', label: 'Send to Club Log' },
|
{ service: 'clublog', name: 'Club Log' },
|
||||||
{ service: 'lotw', label: 'Send to LoTW' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
|
{ 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
|
||||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
// 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,
|
||||||
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
|
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 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();
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
window.addEventListener('mousedown', close);
|
window.addEventListener('mousedown', close);
|
||||||
window.addEventListener('scroll', close, true);
|
|
||||||
window.addEventListener('resize', close);
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', close);
|
window.removeEventListener('mousedown', close);
|
||||||
window.removeEventListener('scroll', close, true);
|
|
||||||
window.removeEventListener('resize', close);
|
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [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">
|
||||||
{n} QSO{n > 1 ? 's' : ''} selected
|
{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(); }}
|
||||||
>
|
>
|
||||||
<Globe2 className="size-4 text-primary" />
|
<Globe2 className="size-4 text-primary" />
|
||||||
<span>Fix country & zones from cty.dat</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(); }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4 text-sky-600" />
|
<RefreshCw className="size-4 text-info" />
|
||||||
<span>Update from QRZ.com</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"
|
||||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<BadgeCheck className="size-4 text-violet-600" />
|
<BadgeCheck className="size-4 text-info" />
|
||||||
<span>Update from ClubLog (exceptions)</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
|
</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -89,8 +152,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
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={() => { onSendEQSL(menu.ids); onClose(); }}
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-amber-600" />
|
<Mail className="size-4 text-warning" />
|
||||||
<span>Send OpsLog QSL by e-mail</span>
|
<span>{t('qctx.sendQslEmail')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSendRecording && (
|
{onSendRecording && (
|
||||||
@@ -98,13 +161,26 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
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={() => { onSendRecording(menu.ids); onClose(); }}
|
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-rose-600" />
|
<Mail className="size-4 text-danger" />
|
||||||
<span>Send recording by e-mail</span>
|
<span>{t('qctx.sendRecording')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBulkEdit && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onExportSelected || onExportFiltered) && (
|
{(onExportSelected || onExportFiltered) && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
@@ -113,8 +189,31 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
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={() => { onExportSelected(menu.ids); onClose(); }}
|
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-sky-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>Export selected to ADIF ({n})</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
|
</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 && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
@@ -122,8 +221,35 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
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={() => { onExportFiltered(); onClose(); }}
|
onClick={() => { onExportFiltered(); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-violet-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>Export filtered view to ADIF (no limit)</span>
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||||
|
</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 && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-warning" />
|
||||||
|
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExportCabrilloFiltered && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-warning" />
|
||||||
|
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -132,18 +258,31 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
{onSendTo && (
|
{onSendTo && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
{UPLOAD_TARGETS.map((t) => (
|
{UPLOAD_TARGETS.map((target) => (
|
||||||
<button
|
<button
|
||||||
key={t.service}
|
key={target.service}
|
||||||
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={() => { onSendTo(t.service, menu.ids); onClose(); }}
|
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Upload className="size-4 text-emerald-600" />
|
<Upload className="size-4 text-success" />
|
||||||
<span>{t.label}</span>
|
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onDelete && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
|
||||||
|
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
<span>{t('qctx.delete', { n })}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } 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 { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -20,6 +21,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
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 { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
@@ -35,14 +38,20 @@ function pfxOf(call: string): string {
|
|||||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
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).
|
||||||
|
// 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: '—' },
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
{ value: 'Y', label: 'Yes' },
|
{ value: 'Y', label: 'qedit.qslYes' },
|
||||||
{ value: 'N', label: 'No' },
|
{ value: 'V', label: 'qedit.qslVerified' },
|
||||||
{ value: 'R', label: 'Requested' },
|
{ value: 'N', label: 'qedit.qslNo' },
|
||||||
{ value: 'I', label: 'Ignore' },
|
{ value: 'R', label: 'qedit.qslRequested' },
|
||||||
|
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||||
];
|
];
|
||||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
@@ -53,30 +62,73 @@ 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 },
|
||||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
];
|
];
|
||||||
|
// i18n label keys for confirmation channels whose label has translatable words
|
||||||
|
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
|
||||||
|
const CONF_LABEL_KEYS: Record<string, string> = {
|
||||||
|
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 v = (value || '').toUpperCase();
|
const v = (value || '').toUpperCase();
|
||||||
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
|
||||||
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
|
||||||
if (v === '') {
|
if (v === '') {
|
||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
|
// One colour per state, and each colour means something:
|
||||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
// Yes green — confirmed, the thing you wanted
|
||||||
: v === 'R' ? 'bg-orange-400 text-white'
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
: v === 'I' ? 'bg-stone-400 text-white'
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
: 'bg-amber-400 text-amber-950';
|
// This is the ONLY state asking for action, so it gets the
|
||||||
|
// only alarming colour.
|
||||||
|
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||||
|
// every row; painting that orange (as it used to be, in the
|
||||||
|
// same orange as Requested) made the table shout about a
|
||||||
|
// 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.
|
||||||
|
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' || v === 'V' ? 'bg-success text-success-foreground border border-success'
|
||||||
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
|
: 'bg-muted text-muted-foreground border border-border';
|
||||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,18 +179,92 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdifDateInput — a real date picker over an ADIF date.
|
||||||
|
//
|
||||||
|
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||||
|
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||||
|
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||||
|
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||||
|
// reimplement and get wrong.
|
||||||
|
//
|
||||||
|
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||||
|
// a plain text box instead, so it stays visible and correctable rather than
|
||||||
|
// silently disappearing behind an empty picker.
|
||||||
|
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||||
|
const raw = (value ?? '').trim();
|
||||||
|
const valid = /^\d{8}$/.test(raw);
|
||||||
|
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||||
|
const today = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||||
|
// wrong one.
|
||||||
|
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||||
|
};
|
||||||
|
if (raw !== '' && !valid) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={iso}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value; // "" when cleared
|
||||||
|
onChange(v ? v.replace(/-/g, '') : '');
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||||
|
onClick={today} title="Today (UTC)">
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
</SelectContent>
|
||||||
</Select>
|
</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();
|
||||||
// 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);
|
||||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||||
// imported/legacy value is never silently dropped from the dropdown.
|
// imported/legacy value is never silently dropped from the dropdown.
|
||||||
@@ -153,6 +279,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return base;
|
return base;
|
||||||
}, [modes, qso.mode]);
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
|
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||||||
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
useEffect(() => {
|
||||||
|
GetListsSettings()
|
||||||
|
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
@@ -177,6 +310,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||||
const awardFieldRef = useRef<Record<string, string>>({});
|
const awardFieldRef = useRef<Record<string, string>>({});
|
||||||
const [awardRefs, setAwardRefs] = useState('');
|
const [awardRefs, setAwardRefs] = useState('');
|
||||||
|
// The refs present when the editor opened, so on save we can tell which ones
|
||||||
|
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||||||
|
const seedAwardRefsRef = useRef('');
|
||||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||||
|
|
||||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||||
@@ -197,6 +333,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
.filter((r: any) => r.pickable)
|
.filter((r: any) => r.pickable)
|
||||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
|
seedAwardRefsRef.current = seed;
|
||||||
setAwardRefs(seed);
|
setAwardRefs(seed);
|
||||||
} catch { /* leave manual refs empty on failure */ }
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
})
|
})
|
||||||
@@ -215,6 +352,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||||
|
|
||||||
|
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
|
||||||
|
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
|
||||||
|
// accepts letters (sections/zones), not just numbers.
|
||||||
|
function setExchange(which: 'srx' | 'stx', raw: string) {
|
||||||
|
const t = raw.trim();
|
||||||
|
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
|
||||||
|
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
|
||||||
|
}
|
||||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||||
setDraft((d) => ({ ...d, [key]: value }));
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
}
|
}
|
||||||
@@ -234,23 +379,35 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||||
async function fetchLookup() {
|
async function fetchLookup() {
|
||||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||||
if (!call) { setLocalErr('Callsign required'); return; }
|
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||||
setLooking(true);
|
setLooking(true);
|
||||||
setLocalErr('');
|
setLocalErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await LookupCallsign(call);
|
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||||
|
// 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
|
||||||
|
// appeared to do nothing.
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// record with no grid used to wipe the grid that was already there.
|
||||||
|
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||||
setDraft((d) => ({
|
setDraft((d) => ({
|
||||||
...d,
|
...d,
|
||||||
name: r.name ?? d.name,
|
name: keep(r.name, d.name),
|
||||||
qth: r.qth ?? d.qth,
|
qth: keep(r.qth, d.qth),
|
||||||
address: r.address ?? (d as any).address,
|
address: keep(r.address, (d as any).address),
|
||||||
email: r.email ?? (d as any).email,
|
email: keep(r.email, (d as any).email),
|
||||||
country: r.country ?? d.country,
|
country: keep(r.country, d.country),
|
||||||
grid: r.grid ?? d.grid,
|
grid: keep(r.grid, d.grid),
|
||||||
state: r.state ?? d.state,
|
state: keep(r.state, d.state),
|
||||||
cnty: r.cnty ?? d.cnty,
|
cnty: keep(r.cnty, d.cnty),
|
||||||
cont: r.cont ?? d.cont,
|
cont: keep(r.cont, d.cont),
|
||||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||||
dxcc: r.dxcc || d.dxcc,
|
dxcc: r.dxcc || d.dxcc,
|
||||||
cqz: r.cqz || d.cqz,
|
cqz: r.cqz || d.cqz,
|
||||||
ituz: r.ituz || d.ituz,
|
ituz: r.ituz || d.ituz,
|
||||||
@@ -258,14 +415,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
lon: r.lon || d.lon,
|
lon: r.lon || d.lon,
|
||||||
}));
|
}));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
|
||||||
} finally {
|
} finally {
|
||||||
setLooking(false);
|
setLooking(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setLocalErr('');
|
setLocalErr('');
|
||||||
const out: any = {
|
const out: any = {
|
||||||
@@ -309,7 +466,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// the dedicated columns, then route the picked refs back onto the payload
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||||
onSave(out);
|
onSave(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,27 +484,47 @@ 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-4xl 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">
|
||||||
<DialogHeader className="flex-row items-baseline gap-2">
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
<DialogTitle>Edit QSO</DialogTitle>
|
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||||
<TabsList className="px-3 overflow-x-auto">
|
<TabsList className="px-3 overflow-x-auto">
|
||||||
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
|
||||||
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
|
||||||
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
|
||||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
|
||||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
|
||||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
|
||||||
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
|
||||||
<TabsTrigger value="extras">
|
<TabsTrigger value="extras">
|
||||||
ADIF fields
|
{t('qedit.tabAdifFields')}
|
||||||
{extrasCount > 0 && (
|
{extrasCount > 0 && (
|
||||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -365,48 +542,64 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{/* Top: Callsign + RST + Fetch */}
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<div className="flex items-end gap-2 mb-3">
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label>Callsign</Label>
|
<div className="flex items-center">
|
||||||
|
<Label>{t('qedit.callsign')}</Label>
|
||||||
|
{(draft.callsign ?? '').trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||||
|
onClick={() => {
|
||||||
|
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||||
|
>
|
||||||
|
QRZ ↗
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>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">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)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">RX Band</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
|
||||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Mode</Label>
|
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
|
||||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Country</Label>
|
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
|
||||||
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
|
||||||
onChange={onCountryChange} className="flex-1" />
|
onChange={onCountryChange} className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -414,16 +607,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
<Label>CQ</Label>
|
<Label>CQ</Label>
|
||||||
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
|
||||||
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Freq</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">RX Freq</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
@@ -431,7 +624,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* ── Right column ── */}
|
{/* ── Right column ── */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||||
@@ -440,16 +633,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// Prefill an empty end with the start time so the user
|
// Prefill an empty end with the start time so the user
|
||||||
// only tweaks the minutes instead of typing a full date.
|
// only tweaks the minutes instead of typing a full date.
|
||||||
if (on && !dateOff) setDateOff(dateOn);
|
if (on && !dateOff) setDateOff(dateOn);
|
||||||
}} /> QSO End (UTC)
|
}} /> {t('qedit.qsoEnd')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-col flex-1"><Label>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>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>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>
|
||||||
@@ -458,10 +654,10 @@ 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>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||||
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Continent</Label>
|
<Label>{t('qedit.continent')}</Label>
|
||||||
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -470,18 +666,30 @@ 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)}
|
||||||
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
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>
|
</div>
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<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>QSL Msg</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>QSL Via</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>
|
||||||
@@ -495,13 +703,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* Right: computed awards (read-only) derived from this QSO */}
|
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
<span className="text-xs font-semibold">Computed (automatic)</span>
|
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
{t('qedit.computedHint')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||||
{computedRefs.length === 0 ? (
|
{computedRefs.length === 0 ? (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
|
||||||
) : (
|
) : (
|
||||||
computedRefs.map((r) => (
|
computedRefs.map((r) => (
|
||||||
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||||
@@ -520,53 +728,147 @@ 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 */}
|
||||||
<div className="flex-1 max-w-sm space-y-3">
|
<div className="flex-1 max-w-sm space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Manage Confirmation</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}>{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 className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
<div><Label>Received</Label>
|
<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><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.received')}</Label>
|
||||||
{def.rcvd
|
{def.rcvd
|
||||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
: <Input disabled value="—" />}
|
: <Input disabled value="—" />}
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||||
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></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>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></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">
|
||||||
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</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.
|
||||||
<div className="w-72 shrink-0">
|
Sized by its content, not pinned to a width: a fixed 288px box
|
||||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||||
|
two lines and padded out the whole row. There is spare width to
|
||||||
|
the right — spend it on the label. */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="text-left font-semibold">Type</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
<th className="font-semibold">Sent</th>
|
<th className="font-semibold">{t('qedit.sent')}</th>
|
||||||
<th className="font-semibold">Received</th>
|
<th className="font-semibold">{t('qedit.received')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
<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>
|
||||||
@@ -577,90 +879,92 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||||
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||||
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||||
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||||
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||||
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||||
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
|
||||||
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sat" className="mt-0">
|
<TabsContent value="sat" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="Propagation mode">
|
<F label={t('qedit.propMode')}>
|
||||||
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</F>
|
</F>
|
||||||
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||||
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||||
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="mystation" className="mt-0 space-y-3">
|
<TabsContent value="mystation" className="mt-0 space-y-3">
|
||||||
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="Station callsign" 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="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="My grid"><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="Grid ext"><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="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="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="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="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>
|
||||||
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
||||||
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||||
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||||
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||||
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||||
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||||
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||||
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
||||||
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||||
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Power & propagation */}
|
{/* Power & propagation */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</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">
|
||||||
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
{/* YOUR power first, then theirs. tx_pwr was saved by this
|
||||||
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
editor but had no field: a contact logged at the wrong
|
||||||
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
power could be read back and never corrected. */}
|
||||||
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
<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.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.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Identity & clubs */}
|
{/* Identity & clubs */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity & clubs</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||||
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
||||||
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
||||||
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||||
@@ -670,28 +974,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
{/* Flags & credits */}
|
{/* Flags & credits */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||||
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||||
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||||
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
||||||
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||||
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My station extras */}
|
{/* My station extras */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||||
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||||
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||||
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||||
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||||
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -704,11 +1008,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
<DialogFooter className="!flex-row gap-2">
|
<DialogFooter className="!flex-row gap-2">
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> {t('qedit.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
|
||||||
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user