Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -46,3 +46,4 @@ cat.log
|
|||||||
.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.
|
||||||
+80
-20
@@ -1,7 +1,11 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
<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
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes 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
|
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||||
@@ -28,7 +32,7 @@ Développé par **F4BPO**.
|
|||||||
|
|
||||||
## Journalisation
|
## Journalisation
|
||||||
|
|
||||||
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
- **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
|
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.
|
**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
|
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||||
@@ -38,7 +42,10 @@ Développé par **F4BPO**.
|
|||||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
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),
|
- **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
|
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||||
QSL.
|
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,
|
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||||
sélectionnées.
|
sélectionnées.
|
||||||
@@ -63,7 +70,9 @@ Développé par **F4BPO**.
|
|||||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||||
rotor.
|
rotor.
|
||||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||||
PstRotator.
|
**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
|
- **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
|
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.
|
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||||
@@ -77,10 +86,12 @@ Développé par **F4BPO**.
|
|||||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||||
panadapter).
|
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
|
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||||
`api.pota.app`).
|
`api.pota.app`).
|
||||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
- **Alertes de spots :** règles sur indicatif / pays / bande /
|
||||||
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||||
*Gestion des alertes*).
|
*Gestion des alertes*).
|
||||||
|
|
||||||
@@ -93,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
|
|||||||
supportée par OmniRig.
|
supportée par OmniRig.
|
||||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
- **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
|
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
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
|
- **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
|
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||||
RS-BA1 ni Remote Utility nécessaires.
|
RS-BA1 ni Remote Utility nécessaires.
|
||||||
@@ -110,9 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
|||||||
|
|
||||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
- **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.
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
- **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
|
- **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.
|
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||||
|
|
||||||
@@ -159,9 +179,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||||
distance) ou **TCI**.
|
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
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||||
émettre.
|
émettre.
|
||||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||||
@@ -170,10 +191,33 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
|
|
||||||
## Amplis & commutateurs
|
## Amplis & commutateurs
|
||||||
|
|
||||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||||
sélecteur de mode ventilateur et affichage des défauts.
|
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
|
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||||
commutation A/B ancré.
|
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
|
## QSL & diplômes
|
||||||
|
|
||||||
@@ -201,13 +245,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||||
direct.
|
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)
|
## 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.
|
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
**TM74TFR**), la publication est **automatique** — aucun réglage à activer.
|
||||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
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
|
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||||
~15 s. Un petit rendu PHP
|
~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
|
([`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
|
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
|
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||||
@@ -217,8 +271,11 @@ ce n'est pas un serveur web.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
- **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 tout le
|
session active en mémoire — pointez les stations présentes, puis loguez-les une
|
||||||
net d'un coup en utilisant la fréquence CAT.
|
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
|
## Apparence & langue
|
||||||
|
|
||||||
@@ -247,7 +304,10 @@ ce n'est pas un serveur web.
|
|||||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
- **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.
|
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
- **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
|
- **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'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||||
désactivable dans les Préférences).
|
désactivable dans les Préférences).
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
|
||||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||||
@@ -27,7 +31,7 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||||
**flag** is shown large next to the RST fields.
|
**flag** is shown large next to the RST fields.
|
||||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
@@ -36,7 +40,10 @@ Developed by **F4BPO**.
|
|||||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
ClubLog DXpedition date overrides.
|
ClubLog DXpedition date overrides.
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||||
|
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||||
|
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||||
|
the first page).
|
||||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||||
presets) with filtered- and selected-row **ADIF export**.
|
presets) with filtered- and selected-row **ADIF export**.
|
||||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||||
@@ -55,7 +62,10 @@ Developed by **F4BPO**.
|
|||||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||||
|
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||||
|
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||||
|
using its Yaesu GS-232A protocol.
|
||||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
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.
|
compass and the map, so you never lose track of where the antenna points.
|
||||||
@@ -67,9 +77,11 @@ Developed by **F4BPO**.
|
|||||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
pane, with a show/hide toggle.
|
pane, with a show/hide toggle.
|
||||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||||
|
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||||
|
the worked-before matrix badges (Settings → General).
|
||||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
- **Spot alerts:** rules on call / country / band / mode /
|
||||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||||
management*).
|
management*).
|
||||||
|
|
||||||
@@ -81,7 +93,8 @@ non-blocking connect so a powered-off radio never freezes the app:
|
|||||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||||
the Flex display, click → fill the call).
|
the Flex display; a click fills the call and **tunes the rig to the right
|
||||||
|
frequency AND mode**).
|
||||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||||
Remote Utility needed.
|
Remote Utility needed.
|
||||||
@@ -98,9 +111,16 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
|
|
||||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
- **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||||
|
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||||
|
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||||
|
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||||
|
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||||
|
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||||
|
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||||
|
in parallel). See *Amplifiers & switches* below.
|
||||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
- **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.
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
@@ -141,18 +161,40 @@ as Mumble.)
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||||
|
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||||
|
works over the remote link too) or **TCI**.
|
||||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
## Amplifiers & switches
|
## Amplifiers & switches
|
||||||
|
|
||||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||||
|
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||||
|
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||||
|
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||||
|
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||||
|
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||||
selector and fault display.
|
selector and fault display.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||||
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||||
|
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||||
|
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||||
|
over a serial cable that wires the DTR/RTS lines.
|
||||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
antenna-switch widget.
|
antenna-switch widget.
|
||||||
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
|
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||||
|
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||||
|
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||||
|
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||||
|
or a set of bands.
|
||||||
|
|
||||||
## QSL & awards
|
## QSL & awards
|
||||||
|
|
||||||
@@ -176,12 +218,21 @@ as Mumble.)
|
|||||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||||
flags dupes and keeps a live scoreboard.
|
flags dupes and keeps a live scoreboard.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||||
|
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||||
|
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||||
|
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||||
|
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||||
|
|
||||||
## Multi-operator live status (special events)
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
|
||||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||||
`live_status` table every ~15 s. A small PHP renderer
|
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||||
|
after the last logged QSO. A small PHP renderer
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
([`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
|
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
|
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||||
@@ -190,8 +241,11 @@ only writes to the DB — it is not a web server.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||||
in-memory active session — check stations in, then log the whole net at once
|
in-memory active session — check stations in, then log them individually or the
|
||||||
using the CAT frequency.
|
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||||
|
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||||
|
and after each log the next on-air station is selected automatically so you can
|
||||||
|
chain contacts.
|
||||||
|
|
||||||
## Appearance & language
|
## Appearance & language
|
||||||
|
|
||||||
@@ -218,7 +272,10 @@ only writes to the DB — it is not a web server.
|
|||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
OpsLog startup, skipping any already running.
|
OpsLog startup, skipping any already running.
|
||||||
- **Backup:** optional database + ADIF backup at shutdown.
|
- **Backup:** optional database + ADIF backup at shutdown.
|
||||||
- **Update check** at startup with a toast (toggleable).
|
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||||
|
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||||
|
(English / French) on the first launch after an update — reopenable any time
|
||||||
|
from the Help menu.
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
|
|
||||||
|
|||||||
+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,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
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": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+236
-17
@@ -32,12 +32,19 @@ 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"
|
||||||
|
|
||||||
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}"
|
||||||
@@ -122,12 +129,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 +444,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 +499,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 +510,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 +529,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 +671,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,7 +699,8 @@ 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)
|
||||||
@@ -691,3 +748,165 @@ func sampleQSO() qso.QSO {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
keyExtEQSLPassword: 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] }
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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"}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,269 @@
|
|||||||
|
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 (
|
||||||
|
"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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetBandOpenSettings() BandOpenSettings {
|
||||||
|
s := BandOpenSettings{
|
||||||
|
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
|
||||||
|
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
|
||||||
|
Available: pskr.Bands,
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ","))
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1260
File diff suppressed because it is too large
Load Diff
@@ -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,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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3546
-477
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [rules, setRules] = useState<Rule[]>([]);
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
const [draft, setDraft] = useState<Rule | null>(null);
|
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 [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||||
const [emailTo, setEmailTo] = useState('');
|
const [emailTo, setEmailTo] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
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 patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||||
if (!d) return d;
|
if (!d) return d;
|
||||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function del() {
|
async function del() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.id) { setDraft(null); return; }
|
if (!draft.id) { loadDraft(null); return; }
|
||||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
<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">
|
<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>
|
<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={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<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.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||||
{rules.map((r) => (
|
{rules.map((r) => (
|
||||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
<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',
|
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')}>
|
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={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
<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"
|
<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'}
|
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||||
value={(draft.calls ?? []).join('\n')}
|
value={callsText}
|
||||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
onChange={(e) => {
|
||||||
|
setCallsText(e.target.value);
|
||||||
|
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,30 @@
|
|||||||
import { Antenna, X } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type AGAntenna = { index: number; name: string };
|
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||||
export type AGStatus = {
|
export type AGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
antennas: AGAntenna[];
|
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
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
function pretty(name: string): string {
|
function pretty(name: string): string {
|
||||||
@@ -17,20 +33,17 @@ function pretty(name: string): string {
|
|||||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
// Clicking an already-selected port deselects it (port → None).
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
// different element instances), so antenna changes silently did nothing.
|
||||||
status: AGStatus;
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
onClose: () => void;
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
|
||||||
const list = status.antennas ?? [];
|
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
const cls = tx
|
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'
|
? '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'
|
||||||
@@ -49,7 +62,35 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
{letter}
|
{letter}
|
||||||
</button>
|
</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 (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
@@ -57,6 +98,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
<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="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
<span className="flex-1" />
|
<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="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={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 className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
@@ -88,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
return (
|
return (
|
||||||
<div key={a.index} className="flex items-center gap-1.5">
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
<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)}>
|
<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)}
|
{pretty(a.name)}
|
||||||
</div>
|
</div>
|
||||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { GetRowColors, 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';
|
||||||
|
|
||||||
|
// 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'] as const;
|
||||||
|
const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog, ExportAward,
|
||||||
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -30,12 +32,16 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
|
|||||||
export type AwardDef = {
|
export type AwardDef = {
|
||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||||
|
leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
// The "custom" confirmation source: the field it reads and, optionally, the
|
||||||
|
// value(s) that count. Empty value = any non-empty value confirms.
|
||||||
|
confirm_field?: string; confirm_value?: string;
|
||||||
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AwardOrRule = {
|
type AwardOrRule = {
|
||||||
@@ -60,7 +66,7 @@ const CONFIRM_SRC = [
|
|||||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||||
];
|
];
|
||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||||
|
|
||||||
@@ -91,6 +97,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||||
|
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||||
|
function openEnded(d?: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||||
|
}
|
||||||
|
|
||||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
@@ -153,6 +166,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||||
|
// the selected award's current version whenever the selection changes.
|
||||||
|
const [catVer, setCatVer] = useState('1');
|
||||||
|
|
||||||
// The err banner doubles as a success/notice area (export path, import counts,
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
@@ -176,7 +192,91 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||||
|
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||||
|
// unless it has been exported. The list flags those.
|
||||||
|
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||||
|
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||||
|
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||||
|
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||||
|
|
||||||
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||||
|
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importPreview) return;
|
||||||
|
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||||
|
// an award because the operator clicked through a dialog without reading it.
|
||||||
|
const d: Record<string, string> = {};
|
||||||
|
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||||
|
setDecisions(d);
|
||||||
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||||
|
|
||||||
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
|
type Rejected = { candidate: string; reason: string };
|
||||||
|
type Step = {
|
||||||
|
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||||
|
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||||
|
skipped?: boolean; error?: string;
|
||||||
|
};
|
||||||
|
type Explanation = {
|
||||||
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
|
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||||
|
};
|
||||||
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
|
const [testCall, setTestCall] = useState('');
|
||||||
|
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||||
|
const [testErr, setTestErr] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const runTest = async () => {
|
||||||
|
if (!cur) return;
|
||||||
|
setTesting(true); setTestErr(''); setTestRows(null);
|
||||||
|
try {
|
||||||
|
const r = await ExplainAward(cur.code, testCall);
|
||||||
|
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||||
|
// so, rather than let the operator test a rule they only think they applied.
|
||||||
|
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||||
|
|
||||||
|
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||||
|
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||||
|
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||||
|
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||||
|
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||||
|
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||||
|
const testGroups = useMemo(() => {
|
||||||
|
if (!testRows) return null;
|
||||||
|
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||||
|
for (const row of testRows) {
|
||||||
|
const key = JSON.stringify(row.explanation);
|
||||||
|
const g = groups.find((x) => x.key === key);
|
||||||
|
if (g) g.rows.push(row);
|
||||||
|
else groups.push({ key, rows: [row] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [testRows]);
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -214,17 +314,69 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Import an award bundle: definitions are upserted by code, reference lists
|
// Export the SELECTED award on its own — the unit you actually share. The
|
||||||
// replaced. Reloads the editor afterwards.
|
// bundle above is a backup: sending it to someone hands over your whole
|
||||||
|
// catalogue when they asked for one award.
|
||||||
|
//
|
||||||
|
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||||
|
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||||
|
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||||
|
// someone else's work rather than as a pristine built-in.
|
||||||
|
async function exportOne() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
try {
|
||||||
|
const code = cur.code.trim().toUpperCase();
|
||||||
|
const p = await ExportAward(code);
|
||||||
|
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
|
async function exportForCatalog() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
const v = Math.trunc(Number(catVer));
|
||||||
|
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||||
|
try {
|
||||||
|
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||||
|
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Import: LOOK FIRST, then ask.
|
||||||
|
//
|
||||||
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
|
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||||
|
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||||
|
// so it must not be a data-loss trap.
|
||||||
async function importAwards() {
|
async function importAwards() {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await ImportAwards();
|
const p: any = await InspectAwardImport();
|
||||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
if (!p?.path) return; // cancelled
|
||||||
|
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||||
|
if (clashes.length === 0) {
|
||||||
|
// Nothing collides — nothing to ask. This is the common case.
|
||||||
|
const dec: Record<string, string> = {};
|
||||||
|
for (const e of p.awards) dec[e.code] = 'replace';
|
||||||
|
await applyImport(p.path, dec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportPreview(p); // → the collision dialog decides
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const r: any = await ApplyAwardImport(path, decisions);
|
||||||
|
setImportPreview(null);
|
||||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
setDefs((d ?? []) as any); setSel(0);
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
onSaved();
|
onSaved();
|
||||||
|
if (r?.awards || r?.references) {
|
||||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||||
|
}
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
@@ -245,14 +397,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||||
{/* Left: award list */}
|
{/* Left: award list */}
|
||||||
<div className="border-r flex flex-col min-h-0">
|
<div className="border-r flex flex-col min-w-0 min-h-0">
|
||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
@@ -260,33 +412,93 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{filtered.map(({ d, i }) => (
|
{filtered.map(({ d, i }) => {
|
||||||
|
// An award that is NOT in the shipped catalog exists only in THIS
|
||||||
|
// database: nobody else has it, and a reinstall loses it unless it
|
||||||
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
|
// discovered the hard way.
|
||||||
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
|
// A pending update is only reachable from the award's own banner, so
|
||||||
|
// the list has to say which award to open — otherwise the fix waits
|
||||||
|
// behind a click nobody knows to make.
|
||||||
|
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||||
|
return (
|
||||||
<button key={i} onClick={() => setSel(i)}
|
<button key={i} onClick={() => setSel(i)}
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
{hasUpdate && (
|
||||||
|
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||||
|
<ArrowUpCircle className="size-3.5 text-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onlyHere && (
|
||||||
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
|
title={t('awed.onlyHereTip')}>
|
||||||
|
{t('awed.onlyHere')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award.
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
|
||||||
|
track has min-width:auto — it refuses to shrink below its content's
|
||||||
|
intrinsic width, so a wide row (the band/mode chip lists, a long
|
||||||
|
translated label) grew the track and pushed the whole editor out
|
||||||
|
past the dialog instead of scrolling inside it. Same reason on the
|
||||||
|
Tabs below: a flex item defaults to min-width:auto too. */}
|
||||||
|
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||||
|
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||||
|
apply it — that would destroy their work — so we offer it, and say
|
||||||
|
plainly what accepting costs. */}
|
||||||
|
{cur && selUpdate && (
|
||||||
|
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||||
|
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ApplyAwardUpdate(cur.code);
|
||||||
|
// The backend rewrote this award (and its references) — pull the
|
||||||
|
// new state back, or the editor would keep showing, and on the
|
||||||
|
// next Save re-persist, the definition we just replaced.
|
||||||
|
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||||
|
loadMeta();
|
||||||
|
loadUpdates();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateApply')}</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateKeepMine')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||||
<TabsList className="px-3 justify-start">
|
<TabsList className="px-3 justify-start">
|
||||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
@@ -296,11 +508,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
|
the catalog derives that on load. Asking the author to tick a
|
||||||
|
box to declare it would be one more step nobody can guess —
|
||||||
|
forget it and the award silently misses every future catalog
|
||||||
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
|
IS a real choice. */}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
|
</label>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||||
|
<Field2 label={t('awed.refDisplay')}>
|
||||||
|
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||||
|
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||||
|
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||||
@@ -327,7 +558,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
|
{/* No "allow multiple references" switch: a QSO always counts for
|
||||||
|
every reference its field holds (an n-fer POTA activation, a
|
||||||
|
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||||
|
|
||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
@@ -348,14 +581,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
|
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional OR searches: a QSO earns a reference if the
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
primary rule OR any of these match. */}
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
|
so a value already resolved by the primary rule isn't
|
||||||
|
re-derived differently by a later one. */}
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
@@ -412,14 +649,143 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
{/* "Custom" is only meaningful once it names a field, so the
|
||||||
|
inputs appear as soon as it is ticked in either column —
|
||||||
|
and a custom source with no field confirms nothing. */}
|
||||||
|
{((cur.confirm ?? []).includes('custom') || (cur.validate ?? []).includes('custom')) && (
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-xs font-semibold">{t('awed.customLabel')}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">{t('awed.customField')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="APP_OPSLOG_QSL_RCVD"
|
||||||
|
value={(cur as any).confirm_field ?? ''}
|
||||||
|
onChange={(e) => patch({ confirm_field: e.target.value.trim().toUpperCase() } as any)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">{t('awed.customValue')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="Y,V"
|
||||||
|
value={(cur as any).confirm_value ?? ''}
|
||||||
|
onChange={(e) => patch({ confirm_value: e.target.value } as any)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('awed.customHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
|
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||||
|
did nothing at all. The stored values are kept (see award.Def);
|
||||||
|
put the controls back when the export is actually wired up. */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Test ──
|
||||||
|
Runs the SAVED award against a real QSO and shows every rule:
|
||||||
|
what it scanned, what it produced, and why each candidate was
|
||||||
|
rejected. An award that matches nothing used to be a black box. */}
|
||||||
|
<TabsContent value="test" className="mt-0 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||||
|
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||||
|
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||||
|
{t('awed.testRun')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||||
|
|
||||||
|
{testGroups?.map((g, ri) => {
|
||||||
|
const row = g.rows[0];
|
||||||
|
const ex = row.explanation;
|
||||||
|
const matched = (ex.result ?? []).length > 0;
|
||||||
|
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||||
|
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||||
|
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||||
|
{g.rows.length > 1 && (
|
||||||
|
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||||
|
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ex.in_scope ? (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{(ex.steps ?? []).map((s, si) => (
|
||||||
|
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||||
|
</span>
|
||||||
|
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||||
|
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||||
|
</div>
|
||||||
|
{!s.skipped && !s.error && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||||
|
{s.field_value
|
||||||
|
? <span className="font-mono">{s.field_value}</span>
|
||||||
|
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||||
|
</div>
|
||||||
|
{(s.kept ?? []).map((k) => (
|
||||||
|
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||||
|
))}
|
||||||
|
{(s.rejected ?? []).map((r, i2) => (
|
||||||
|
<div key={i2} className="font-mono text-muted-foreground">
|
||||||
|
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||||
|
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(ex.ambiguous ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||||
|
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(ex.manual ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
|
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── References ── */}
|
{/* ── References ── */}
|
||||||
<TabsContent value="refs" className="mt-0">
|
<TabsContent value="refs" className="mt-0">
|
||||||
<ReferencesPanel
|
<ReferencesPanel
|
||||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
onChanged={loadMeta} setErr={setErr}
|
onChanged={loadMeta} setErr={setErr}
|
||||||
/>
|
/>
|
||||||
@@ -430,19 +796,110 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
{/* Eight buttons on one unwrappable row fitted in English and overflowed
|
||||||
|
in French, where the labels are half again as long. Wrap instead of
|
||||||
|
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
|
||||||
|
margin-left approach leaves no vertical gap once a row wraps. */}
|
||||||
|
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
|
||||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||||
|
export because that is where an operator looks for it, and labelled
|
||||||
|
with the code so the two are never confused at a glance. */}
|
||||||
|
{cur && (
|
||||||
|
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||||
|
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||||
|
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||||
|
then has to retype. */}
|
||||||
|
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('awed.awardsFolderTip')}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
|
</Button>
|
||||||
|
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||||
|
file to paste over internal/award/catalog/<code>.json, so a release
|
||||||
|
ships your change to the whole team. */}
|
||||||
|
{cur && (
|
||||||
|
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||||
|
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||||
|
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||||
|
<Button variant="outline" onClick={exportForCatalog}>
|
||||||
|
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Collision dialog. The import cannot silently replace an award you already
|
||||||
|
have — importing a shared WAPC used to destroy yours (province list, city
|
||||||
|
regexes, band scope) without a word. You decide, per award. */}
|
||||||
|
{importPreview && (
|
||||||
|
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||||
|
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||||
|
{importPreview.awards.map((e) => (
|
||||||
|
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('awed.importRefs', { n: e.references })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!e.exists ? (
|
||||||
|
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||||
|
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||||
|
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
['skip', t('awed.importKeepMine')],
|
||||||
|
['replace', t('awed.importReplace')],
|
||||||
|
['copy', t('awed.importCopy', { code: e.code })],
|
||||||
|
] as [string, string][]).map(([v, label]) => (
|
||||||
|
<button key={v} type="button"
|
||||||
|
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||||
|
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||||
|
decisions[e.code] === v
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||||
|
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||||
|
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,8 +907,8 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
// the online updater (POTA/SOTA/WWFF).
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -595,26 +1052,38 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* One per row: side by side, each half spends 120px of its width on
|
||||||
|
the label column and the input is left too narrow to read a group
|
||||||
|
name ("Basilicata" → "Basili"). */}
|
||||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||||
</div>
|
|
||||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
boxes were pure decoration. The columns stay in the database — a
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
|
third-party list may carry the values — but they are not offered
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
for editing until something actually reads them. */}
|
||||||
</div>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
{/* This reference's own validity window. A reference is not forever:
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
|
a park is delisted, a district merged. A QSO made while it
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
existed still counts — it was a valid contact on the day — and
|
||||||
</div>
|
one made afterwards does not.
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
Left empty the award's own dates govern, which is why they show
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
|
as the placeholder: the operator can see what "empty" inherits
|
||||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
instead of having to remember. */}
|
||||||
</div>
|
<Field2 label={t('awed.refValidFrom')}>
|
||||||
</div>
|
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<Field2 label={t('awed.refValidTo')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(awardValidFrom || awardValidTo)
|
||||||
|
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
|
: t('awed.refValidHint')}
|
||||||
|
</p>
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<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-success-border bg-success-muted text-success-muted-foreground 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-success-muted-foreground">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<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">
|
|
||||||
{t('awrs.pickReference')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add — references are always scoped to the contacted DXCC */}
|
{/* Add — references are always scoped to the contacted DXCC */}
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
ref: string; name?: string; group?: string; subgrp?: string;
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
worked: boolean; confirmed: boolean; validated: boolean;
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
|
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||||
|
modes?: string[]; confirmed_modes?: string[];
|
||||||
};
|
};
|
||||||
type AwardResult = {
|
type AwardResult = {
|
||||||
code: string; name: string; dimension: string;
|
code: string; name: string; dimension: string;
|
||||||
@@ -61,11 +66,14 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
// first time it's selected (or when explicitly rescanned).
|
// first time it's selected (or when explicitly rescanned).
|
||||||
|
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||||
|
// are different results, and caching them under one key showed the previous
|
||||||
|
// mode’s numbers after switching.
|
||||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -74,6 +82,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);
|
||||||
@@ -112,13 +124,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 {
|
||||||
@@ -126,13 +139,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[]),
|
||||||
|
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 ?? [] }))
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
if (first) compute(first.code);
|
if (first) compute(first.code);
|
||||||
@@ -141,8 +161,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
|
||||||
@@ -191,17 +220,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">
|
||||||
@@ -220,7 +313,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{t('awp.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">
|
||||||
@@ -240,7 +333,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}
|
||||||
@@ -325,6 +418,14 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
|
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setModeFilter(k)}
|
||||||
|
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
@@ -386,8 +487,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">{t('awp.ref')}</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{hasGroup && (
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.description')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
{gridBands.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
@@ -397,9 +516,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 (
|
||||||
@@ -424,9 +544,24 @@ 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">{t('awp.ref')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.name')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium w-40">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -459,7 +594,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)} />
|
||||||
)}
|
)}
|
||||||
{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} />
|
||||||
@@ -644,17 +779,17 @@ 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 }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
AwardCellQSOs(code, cell.ref, cell.band)
|
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||||
.then((r) => setQsos((r ?? []) as any))
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
.catch(() => setQsos([]))
|
.catch(() => setQsos([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [code, cell.ref, cell.band]);
|
}, [code, cell.ref, cell.band, modeClass]);
|
||||||
|
|
||||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
@@ -684,7 +819,7 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
|||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">{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>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
|
||||||
// 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
|
||||||
@@ -24,7 +26,46 @@ interface Spot {
|
|||||||
spotter?: string;
|
spotter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpotStatusEntry = { status: string; country?: string };
|
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||||
|
// markers were already arriving in this object — the local type simply never
|
||||||
|
// mentioned them, so they could not be drawn.
|
||||||
|
type SpotStatusEntry = {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
// worked_slot: this exact callsign already worked on THIS band and mode.
|
||||||
|
worked_slot?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||||
|
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||||
|
lotw?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||||
|
// entity AND a new park. The cluster list stacks them as separate badges instead
|
||||||
|
// of letting one replace another, so the map stacks them too — as segments of
|
||||||
|
// the pill's left accent bar, which until now only repeated the pill's own
|
||||||
|
// colour and carried no information of its own.
|
||||||
|
//
|
||||||
|
// Their colours come from lib/spotMarkers, shared with the cluster list: the
|
||||||
|
// same fact must not be violet in one panel and green in the next.
|
||||||
|
// The map shows three of the five. A new PREFIX and a new GRID are left to the
|
||||||
|
// cluster list, which has the width to name them: the
|
||||||
|
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
|
||||||
|
// nobody can read at a glance. Add it here the day the strip earns more room.
|
||||||
|
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||||||
|
const markersFor = (e: SpotStatusEntry | undefined) =>
|
||||||
|
activeMarkers(e).filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||||||
|
|
||||||
|
// The legend spells the markers out; the cluster list's badges are abbreviated
|
||||||
|
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
|
||||||
|
const BMP_MARKER_LABEL: Record<string, string> = {
|
||||||
|
new_pota: 'bmp.legendNewPota',
|
||||||
|
new_county: 'bmp.legendNewCounty',
|
||||||
|
worked_call: 'bmp.legendWorkedCall',
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
band: string;
|
band: string;
|
||||||
@@ -41,6 +82,12 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// 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]> = {
|
||||||
@@ -60,25 +107,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-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
'60m': [[5350, 5450, SEG_PHONE]],
|
||||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
'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>
|
||||||
);
|
);
|
||||||
@@ -90,12 +159,26 @@ function statusLabel(s: string, t: (k: string) => string): string {
|
|||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return t('bmp.statusNew');
|
case 'new': return t('bmp.statusNew');
|
||||||
case 'new-band': return t('bmp.statusNewBand');
|
case 'new-band': return t('bmp.statusNewBand');
|
||||||
|
case 'new-mode': return t('bmp.statusNewMode');
|
||||||
case 'new-slot': return t('bmp.statusNewSlot');
|
case 'new-slot': return t('bmp.statusNewSlot');
|
||||||
|
case 'new-call': return t('bmp.statusNewCall');
|
||||||
case 'worked': return t('bmp.statusWorked');
|
case 'worked': return t('bmp.statusWorked');
|
||||||
|
// An empty status means the entity could not be resolved. Nothing else
|
||||||
|
// empties it: the mute option leaves the status alone and takes only the
|
||||||
|
// already-worked-callsign mark.
|
||||||
default: return t('bmp.statusUnresolved');
|
default: return t('bmp.statusUnresolved');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUIET: no status colour at all. The pill keeps the card background and the
|
||||||
|
// accent drops to a plain border grey, so the spot is present but says nothing.
|
||||||
|
const QUIET_STYLE = {
|
||||||
|
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||||
|
bar: 'bg-muted-foreground/30',
|
||||||
|
line: 'stroke-border',
|
||||||
|
dot: 'fill-border',
|
||||||
|
};
|
||||||
|
|
||||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||||
// pill = full pill background+text+border
|
// pill = full pill background+text+border
|
||||||
// bar = thick left accent inside the pill
|
// bar = thick left accent inside the pill
|
||||||
@@ -114,18 +197,14 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
line: 'stroke-warning',
|
line: 'stroke-warning',
|
||||||
dot: 'fill-warning',
|
dot: 'fill-warning',
|
||||||
};
|
};
|
||||||
|
case 'new-call':
|
||||||
case 'new-slot': return {
|
case 'new-slot': return {
|
||||||
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||||
bar: 'bg-caution',
|
bar: 'bg-caution',
|
||||||
line: 'stroke-caution',
|
line: 'stroke-caution',
|
||||||
dot: 'fill-caution',
|
dot: 'fill-caution',
|
||||||
};
|
};
|
||||||
case 'worked': return {
|
case 'worked': return QUIET_STYLE;
|
||||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
|
||||||
bar: 'bg-muted-foreground/30',
|
|
||||||
line: 'stroke-border',
|
|
||||||
dot: 'fill-border',
|
|
||||||
};
|
|
||||||
default: return {
|
default: return {
|
||||||
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
|
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
|
||||||
bar: 'bg-primary/60',
|
bar: 'bg-primary/60',
|
||||||
@@ -153,8 +232,24 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// The two display options are applied ONCE here, on the whole map, so the
|
||||||
|
// leader lines, the dots, the pills and the badges can never disagree — and so
|
||||||
|
// the map and the cluster list share the single rule set in lib/spotDisplay.
|
||||||
|
// Re-derived whenever the poll delivers a new status map, which is also when a
|
||||||
|
// just-changed option takes effect.
|
||||||
|
// Read at render time, not inside the memo: the options must be part of the
|
||||||
|
// dependencies. Keyed only on spotStatusRaw, toggling an option changed
|
||||||
|
// nothing until the next poll happened to hand over a fresh object.
|
||||||
|
const dispOpts = readSpotDisplayOptions();
|
||||||
|
const spotStatus = useMemo(() => {
|
||||||
|
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
||||||
|
const out: Record<string, SpotStatusEntry> = {};
|
||||||
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
|
return out;
|
||||||
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
||||||
@@ -222,6 +317,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
case 'new': return 0;
|
case 'new': 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;
|
||||||
}
|
}
|
||||||
@@ -332,6 +428,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
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">
|
||||||
@@ -411,10 +544,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>
|
||||||
@@ -501,12 +637,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
'hover:translate-x-0.5 hover:shadow',
|
'hover:translate-x-0.5 hover:shadow',
|
||||||
style.pill,
|
style.pill,
|
||||||
)}
|
)}
|
||||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||||
>
|
>
|
||||||
{/* Status accent strip on the left */}
|
{/* Left accent strip. With no extra marker it repeats the status
|
||||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
colour, exactly as before; otherwise it splits into one
|
||||||
|
segment per marker, so "worked entity + new park" shows both
|
||||||
|
instead of one hiding the other. */}
|
||||||
|
{(() => {
|
||||||
|
const marks = markersFor(entry);
|
||||||
|
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
|
||||||
|
return (
|
||||||
|
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
|
||||||
|
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||||
<span>{p.spot.dx_call}</span>
|
<span>{p.spot.dx_call}</span>
|
||||||
|
{/* Same badge the cluster list draws, for the same reason: the
|
||||||
|
muted-blue of a confirmation, never a status colour —
|
||||||
|
whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. A glyph rather than a
|
||||||
|
colour also keeps it off the channel the statuses use. */}
|
||||||
|
{showLotw && entry?.lotw && (
|
||||||
|
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||||
|
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||||
|
)}
|
||||||
{mode && (
|
{mode && (
|
||||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||||
{mode}
|
{mode}
|
||||||
@@ -523,7 +679,17 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||||
|
{/* The stacked markers, straight from the shared table so the legend
|
||||||
|
cannot drift from what the pills actually draw. */}
|
||||||
|
{BMP_MARKERS.map((m) => (
|
||||||
|
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
|
||||||
|
))}
|
||||||
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
|
<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">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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 { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -13,6 +16,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,
|
||||||
@@ -84,7 +97,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
|
// 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(
|
||||||
@@ -94,6 +109,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;
|
||||||
@@ -109,14 +125,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.
|
||||||
@@ -129,6 +152,58 @@ 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(
|
||||||
@@ -136,7 +211,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
@@ -147,12 +222,22 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<strong className="text-warning-muted-foreground 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
|
||||||
@@ -183,6 +268,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">
|
||||||
@@ -222,10 +308,14 @@ 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(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
||||||
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
|
// 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-warning ring-inset',
|
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -253,6 +343,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
))}
|
))}
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldKind = 'status' | 'text';
|
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
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.
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
@@ -21,19 +21,38 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
|||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const FIELDS: FieldDef[] = [
|
const FIELDS: FieldDef[] = [
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
// One channel at a time, each with its status and its date, in the same order
|
||||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
// 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', 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', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
|
||||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
|
||||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_sent_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' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
// 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_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
@@ -49,7 +68,46 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', 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', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', 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
|
// 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: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||||
@@ -65,12 +123,21 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
// Derived from the fields themselves, in the order they are declared.
|
||||||
|
//
|
||||||
|
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||||
|
// simply never rendered — the fields existed, passed every check, and could not
|
||||||
|
// be picked. Two lists that must agree, with nothing to make them.
|
||||||
|
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
'My station': 'bulk.groupMyStation',
|
'My station': 'bulk.groupMyStation',
|
||||||
|
'Contacted station': 'bulk.groupContacted',
|
||||||
|
'Contest': 'bulk.groupContest',
|
||||||
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -85,7 +152,7 @@ type Props = {
|
|||||||
// so they become eligible for upload.
|
// so they become eligible for upload.
|
||||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [field, setField] = useState('hrdlog_upload');
|
const [field, setField] = useState('qsl_sent');
|
||||||
const [statusValue, setStatusValue] = useState('R');
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
const [textValue, setTextValue] = useState('');
|
const [textValue, setTextValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -129,9 +196,12 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||||
{FIELDS.filter((f) => f.group === g).map((f) => (
|
{FIELDS.filter((f) => f.group === g)
|
||||||
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
|
.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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -146,11 +216,35 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={textValue}
|
value={textValue}
|
||||||
placeholder={t('bulk.clearPlaceholder')}
|
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)}
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -158,7 +252,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-danger">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -97,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{isQSLConfirmed(e.lotw_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||||
)}
|
)}
|
||||||
{e.qsl_rcvd === 'Y' && (
|
{isQSLConfirmed(e.qsl_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// 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': 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ 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';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -51,6 +53,22 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -92,23 +110,90 @@ 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(t: TFn, 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
|
||||||
switch (s?.status) {
|
// lined up with the plain callsigns above and below. A whole column of them read
|
||||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
// as chrome rather than as data. Same reasoning — and the same semantic tokens —
|
||||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
// as the Y/N/R QSL columns in lib/qslStatus.ts.
|
||||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
//
|
||||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
// Which cell is marked IS the message:
|
||||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
//
|
||||||
|
// 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) {
|
||||||
|
case 'new':
|
||||||
|
case 'new-band':
|
||||||
|
case 'new-mode':
|
||||||
|
case 'new-slot':
|
||||||
|
case 'new-call':
|
||||||
|
return NEW;
|
||||||
|
default:
|
||||||
|
return s?.worked_call ? WKD : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDull reports a spot that "represents nothing" under the current worked/slot
|
||||||
|
// rules: the entity/band/mode is already worked (status resolved, not new in any
|
||||||
|
// dimension), the callsign itself isn't in the log, and there's no POTA / county
|
||||||
|
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
|
||||||
|
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
|
||||||
|
// what counts as dull follows those settings automatically. Unresolved statuses
|
||||||
|
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||||
|
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||||
|
if (!s || !s.status) return false;
|
||||||
|
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
||||||
|
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||||
@@ -117,22 +202,44 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.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: t('clg2.c.call'), colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
// NEW DXCC fills the call cell. Already worked only tints the text blue:
|
||||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
// 'worked' is not a novelty, and filling it would wash most of the rows.
|
||||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
|
||||||
// the rest of the row across every theme instead of always shouting orange.
|
cellRenderer: (p: any) => {
|
||||||
cellStyle: (p: any): any => {
|
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
|
||||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
return (
|
||||||
return { fontWeight: 700 };
|
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
|
||||||
|
{p.value ?? ''}
|
||||||
|
{/* A single letter rather than a word: the column is 120 px and holds a
|
||||||
|
callsign. It stays the muted-blue of a confirmation, never a status
|
||||||
|
colour — whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. */}
|
||||||
|
{s?.lotw ? (
|
||||||
|
<span title={t('clu.lotwBadge')} style={{
|
||||||
|
marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
|
||||||
|
background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
|
||||||
|
}}>L</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -141,26 +248,55 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||||
// is obvious at the row level, not just a single coloured cell.
|
// 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) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
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(t('clg2.newGrid'));
|
||||||
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(t, statusFor(p));
|
const s = statusFor(p);
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
const parts: { text: string; color: string }[] = [];
|
||||||
|
const main = statusColor(s);
|
||||||
|
if (main) {
|
||||||
|
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||||
|
: 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') });
|
||||||
|
if (s?.new_grid) parts.push({ text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
||||||
|
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
{parts.map((pt, i) => (
|
||||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||||
}}>{b.text}</span>
|
{i > 0 ? ' · ' : ''}{pt.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
@@ -168,6 +304,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||||
|
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
||||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
@@ -176,7 +313,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#166534' },
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
|
? fillStyle(markerColour('new_pota'))
|
||||||
|
: { color: 'var(--success)' }) as any,
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,10 +330,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → the band cell is filled.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' ? fillStyle(NEW) : null) as any,
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
: undefined),
|
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,16 +341,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
cellStyle: (p: any) => {
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
const st = statusFor(p)?.status;
|
// that case is signalled by the Status badge alone.
|
||||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-mode' ? fillStyle('var(--caution)') : null) as any,
|
||||||
// Status badge text tells them apart.
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
@@ -224,7 +358,37 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: '#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: t('clg2.c.grid'), colId: 'grid',
|
||||||
|
headerName: t('clg2.c.grid'), width: 70, cellClass: 'font-mono',
|
||||||
|
// Not on the spot: the square a station announced in a CQ that THIS receiver
|
||||||
|
// decoded over the WSJT-X link, so it is filled for the digital watering
|
||||||
|
// hole being monitored and empty everywhere else. A cluster line carries the
|
||||||
|
// spotter's grid at best, never the DX's.
|
||||||
|
valueGetter: (p: any) => statusFor(p)?.grid ?? '',
|
||||||
|
cellStyle: (p: any) => (statusFor(p)?.new_grid ? fillStyle(markerColour('new_grid')) : null) as any,
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
|
tooltipValueGetter: (p: any) => (statusFor(p)?.new_grid ? t('clg2.tipNewGrid') : undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Geo', label: t('clg2.c.county'), colId: 'county',
|
||||||
|
headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
|
||||||
|
// Not on the spot: resolved per callsign from the offline ULS store, so it
|
||||||
|
// stays empty for non-US calls and until that database is downloaded.
|
||||||
|
valueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (!s?.county) return '';
|
||||||
|
return s.state ? s.county + ', ' + s.state : s.county;
|
||||||
|
},
|
||||||
|
cellStyle: (p: any) => (statusFor(p)?.new_county ? fillStyle(markerColour('new_county')) : null) as any,
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
|
tooltipValueGetter: (p: any) => (statusFor(p)?.new_county ? t('clg2.tipNewCounty') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
@@ -261,7 +425,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
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: t('clg2.c.continent'), colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
@@ -270,31 +434,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
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: t('clg2.c.spotter'), colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: t('clg2.c.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: t('clg2.c.source'), colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: t('clg2.c.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: t('clg2.c.locator'), colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: t('clg2.h.locator'), 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: t('clg2.c.comment'), colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: t('clg2.c.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: t('clg2.c.received_at'), colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
@@ -320,13 +484,36 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
// 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]);
|
});
|
||||||
|
}, [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
|
||||||
@@ -336,24 +523,37 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||||
// turns blue once its status loads.
|
// refresh so e.g. a worked call turns blue once its status loads.
|
||||||
|
//
|
||||||
|
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
|
||||||
|
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
|
||||||
|
// into one refresh every 200 ms (still imperceptible) instead of one per update.
|
||||||
|
const refreshPending = useRef<number | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||||
|
refreshPending.current = window.setTimeout(() => {
|
||||||
|
refreshPending.current = undefined;
|
||||||
gridRef.current?.api?.refreshCells({ force: true });
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
|
}, 200);
|
||||||
}, [spotStatus]);
|
}, [spotStatus]);
|
||||||
|
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// 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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -42,6 +42,7 @@ export interface DetailsState {
|
|||||||
srx_string?: string;
|
srx_string?: string;
|
||||||
stx_string?: string;
|
stx_string?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
web?: string;
|
||||||
// Award references for the contacted station (set via the Awards tab picker).
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
@@ -53,6 +54,14 @@ interface Props {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||||
remoteGrid: string; // entry-strip Grid value — destination
|
remoteGrid: string; // entry-strip Grid value — destination
|
||||||
|
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||||
|
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||||
|
// an award stayed silent until the QSO was logged.
|
||||||
|
qth?: string;
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
comment?: string;
|
||||||
|
note?: string;
|
||||||
details: DetailsState;
|
details: DetailsState;
|
||||||
onChange: (patch: Partial<DetailsState>) => void;
|
onChange: (patch: Partial<DetailsState>) => void;
|
||||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||||
@@ -61,6 +70,14 @@ 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
|
||||||
|
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||||
|
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||||
|
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||||
|
slotCall?: string;
|
||||||
|
slotBand?: string;
|
||||||
|
slotMode?: string;
|
||||||
|
slotWb?: any;
|
||||||
|
slotWbBusy?: boolean;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
@@ -69,6 +86,8 @@ interface Props {
|
|||||||
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||||
// shown as Ctrl+F1…F5 instead of F1…F5.
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||||
keyerActive?: boolean;
|
keyerActive?: boolean;
|
||||||
|
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
|
||||||
|
onEditQso?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
@@ -113,28 +132,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, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
// Live award detection: run the SAME engine used at log time over the current
|
// Live award detection: run the SAME engine used at log time over the current
|
||||||
// contact (callsign + looked-up address/state/zones) so award references the
|
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||||
|
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||||
|
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||||
|
// call or click a spot, instead of only appearing after logging. Pickable
|
||||||
|
// matches are merged into award_refs
|
||||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||||
|
|
||||||
|
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||||
|
// answers, for the station being worked right now. Debounced because the
|
||||||
|
// county field is typed into as well as filled by the lookup.
|
||||||
|
const [newCounty, setNewCounty] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const cnty = (details.cnty ?? '').trim();
|
||||||
|
if (!cnty) { setNewCounty(false); return; }
|
||||||
|
let alive = true;
|
||||||
|
const id = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||||
|
if (alive) setNewCounty(!!isNew);
|
||||||
|
} catch { if (alive) setNewCounty(false); }
|
||||||
|
}, 300);
|
||||||
|
return () => { alive = false; window.clearTimeout(id); };
|
||||||
|
}, [details.state, details.cnty]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
@@ -142,6 +190,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const q: any = {
|
const q: any = {
|
||||||
callsign, band, mode,
|
callsign, band, mode,
|
||||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||||
|
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||||
|
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
qso_date: new Date().toISOString(),
|
qso_date: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -151,7 +201,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const have = new Set(cur.split(';').filter(Boolean));
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
let next = cur;
|
let next = cur;
|
||||||
for (const r of all) {
|
for (const r of all) {
|
||||||
if (!r.pickable) continue;
|
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||||
|
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||||
|
if (!r.pickable || r.ambiguous) continue;
|
||||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||||
}
|
}
|
||||||
@@ -160,7 +212,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}, 400);
|
}, 400);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -174,6 +226,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||||
|
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||||
|
// have one — it is what the operator can see and check — and the provider's
|
||||||
|
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||||
|
const dxLL = useMemo(() => {
|
||||||
|
const byGrid = gridToLatLon(remoteGrid);
|
||||||
|
if (byGrid) return byGrid;
|
||||||
|
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||||
|
return null;
|
||||||
|
}, [remoteGrid, details.lat, details.lon]);
|
||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
@@ -222,19 +283,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
<BandSlotGrid
|
||||||
|
wb={slotCall ? slotWb : wb}
|
||||||
|
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||||
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
|
bands={bands}
|
||||||
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
|
forCall={slotCall}
|
||||||
|
onEditQso={onEditQso}
|
||||||
|
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'info' && (
|
{open === 'info' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||||
<Field label={t('detp.statePref')}>
|
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||||
|
county name is long, a CQ zone is two digits — on an even six-column
|
||||||
|
grid they were the same width, so the county truncated while the
|
||||||
|
zones sat mostly empty. */}
|
||||||
|
<Field label={t('detp.statePref')} span={2}>
|
||||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.county')}>
|
<Field label={t('detp.county')} span={4} className="relative">
|
||||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||||
|
{newCounty && (
|
||||||
|
<span
|
||||||
|
title={t('detp.newCountyTip')}
|
||||||
|
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||||
|
>
|
||||||
|
{t('detp.newCounty')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.prefix')}>
|
<Field label={t('detp.prefix')} span={2}>
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.cqZone')}>
|
<Field label={t('detp.cqZone')}>
|
||||||
@@ -248,7 +330,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
bearing and distances move to the row below. */}
|
bearing and distances move to the row below. */}
|
||||||
<Field label={t('detp.dxcc')}>
|
<Field label={t('detp.dxcc')} span={2}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -257,7 +339,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.azimuthLp')}>
|
{/* The bearing and the two distances are read-only and hold at most
|
||||||
|
"12345 km", so they take a fixed width and the address — the one
|
||||||
|
field here that is never wide enough — gets everything left over. */}
|
||||||
|
<div className="col-span-12 flex gap-2">
|
||||||
|
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -266,7 +352,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceSp')}>
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -275,7 +361,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceLp')}>
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -284,15 +370,20 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.address')} span={3}>
|
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.qslMessage')} span={3}>
|
</div>
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
</Field>
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
<Field label={t('detp.qslVia')} span={2}>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
|
of 12 for text most operators never type. */}
|
||||||
|
<Field label={t('detp.qslVia')} span={7}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t('detp.qslMessage')} span={5}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -305,12 +396,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
|
||||||
|
contact is still in front of you. */}
|
||||||
|
{detected.some((r) => r.ambiguous) && (
|
||||||
|
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||||
|
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||||
|
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||||
|
<button
|
||||||
|
key={`amb-${r.code}@${r.ref}`}
|
||||||
|
type="button"
|
||||||
|
title={r.name ?? ''}
|
||||||
|
onClick={() => {
|
||||||
|
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||||
|
const cur = details.award_refs ?? '';
|
||||||
|
// Only one of the candidates can be right — replace any
|
||||||
|
// sibling already picked for this award rather than stacking.
|
||||||
|
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||||
|
onChange({ award_refs: [...kept, entry].join(';') });
|
||||||
|
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||||
|
}}
|
||||||
|
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||||
|
>
|
||||||
|
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detected.some((r) => !r.ambiguous) && (
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
{detected.map((r) => (
|
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,18 +470,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.rig')} span={3}>
|
<Field label={t('detp.rig')} span={3}>
|
||||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.antenna')} span={3}>
|
<Field label={t('detp.antenna')} span={3}>
|
||||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
{satelliteMode && (
|
{satelliteMode && (
|
||||||
<>
|
<>
|
||||||
<Field label={t('detp.satName')} span={3}>
|
<Field label={t('detp.satName')} span={3}>
|
||||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.satelliteMode')} span={3}>
|
<Field label={t('detp.satelliteMode')} span={3}>
|
||||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -381,8 +499,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.contactedEmail')} span={3}>
|
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
as a value the lookup found, and that is exactly the field an
|
||||||
|
operator checks to see whether it found one. */}
|
||||||
|
<Field label={t('detp.contactedEmail')} span={2}>
|
||||||
|
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('detp.contactedWeb')}>
|
||||||
|
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -12,21 +12,27 @@ 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 { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const anyAudio = messages.some((m) => m.has_audio);
|
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">{t('dvkp.voiceKeyer')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-warning 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" /> {t('dvkp.stop')}
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
{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 ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
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 ? t('dvkp.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,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,139 @@
|
|||||||
|
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]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</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,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
@@ -13,7 +13,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
export type FilterOp =
|
export type FilterOp =
|
||||||
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
||||||
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
|
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty'
|
||||||
|
| 'in' | 'notin';
|
||||||
|
|
||||||
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
||||||
export interface QueryFilter {
|
export interface QueryFilter {
|
||||||
@@ -26,7 +27,9 @@ 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: 'fltb.fCallsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
@@ -57,16 +60,32 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||||
|
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||||
|
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||||
|
// of them — a paper QSL is not uploaded.
|
||||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||||
|
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
|
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
|
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
|
||||||
|
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
|
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
|
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||||
|
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', 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' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
@@ -105,13 +124,21 @@ const OPS: { value: FilterOp; label: string }[] = [
|
|||||||
{ value: 'le', label: 'fltb.opLe' },
|
{ value: 'le', label: 'fltb.opLe' },
|
||||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||||
|
// 'in' is what makes a real question askable. Every condition is joined by ONE
|
||||||
|
// AND or OR, so "2 m or 70 cm, in FT8, since January" had no expression: AND
|
||||||
|
// killed the two bands, OR let every FT8 QSO through. The OR lives inside the
|
||||||
|
// condition instead, and the rest keeps ANDing.
|
||||||
|
{ value: 'in', label: 'fltb.opIn' },
|
||||||
|
{ value: 'notin', label: 'fltb.opNotIn' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'in', 'notin', 'empty', 'notempty'];
|
||||||
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
||||||
|
|
||||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -134,6 +161,12 @@ interface Props {
|
|||||||
|
|
||||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||||
const { t } = useI18n();
|
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>>({});
|
||||||
@@ -210,7 +243,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{n}
|
{n}
|
||||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||||
|
fires too late — the menu has already loaded the preset and
|
||||||
|
closed. Intercept pointer-down (preventDefault stops Radix
|
||||||
|
claiming it) and delete there instead. */}
|
||||||
|
<Trash2
|
||||||
|
className="size-3 text-muted-foreground hover:text-destructive"
|
||||||
|
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -226,6 +266,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
{conditions.map((c, i) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
|
const isList = c.op === 'in' || c.op === 'notin';
|
||||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -237,7 +278,7 @@ 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}>{t(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 })}>
|
||||||
@@ -247,12 +288,32 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
// 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 ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
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={t('fltb.remove')}>
|
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -47,17 +47,74 @@ const ZERO: IcomState = {
|
|||||||
|
|
||||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||||
const BANDS: { l: string; hz: number }[] = [
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
{ l: '6', hz: 50_150_000 },
|
|
||||||
];
|
];
|
||||||
|
const B6 = { l: '6', hz: 50_150_000 };
|
||||||
|
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||||
|
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||||
|
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||||
|
|
||||||
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
|
// the plain SetFrequency command — no band-stacking codes needed.
|
||||||
|
//
|
||||||
|
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||||
|
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||||
|
// through 6 and none of the bands it actually covers: ten dead buttons, and no
|
||||||
|
// way to change band from here on the only rig where you would want to.
|
||||||
|
function bandsFor(model?: string): Band[] {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
|
||||||
|
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
|
||||||
|
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
|
||||||
|
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
|
||||||
|
return [...HF_BANDS, B6];
|
||||||
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
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:
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
function fmtVFO(hz?: number): string {
|
function fmtVFO(hz?: number): string {
|
||||||
@@ -534,7 +591,7 @@ function ScopePanadapter() {
|
|||||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
// 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
|
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||||
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
@@ -637,8 +694,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
{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}
|
{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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig
|
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||||
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
|
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')}
|
<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">
|
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
|
<Power className="size-3.5" /> ON
|
||||||
@@ -647,6 +708,8 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
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">
|
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
|
<Power className="size-3.5" /> OFF
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -710,12 +773,19 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{BANDS.map((b) => (
|
{bandsFor(st.model).map((b) => {
|
||||||
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
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}
|
{b.l}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Row label={t('icmp.antenna')}>
|
<Row label={t('icmp.antenna')}>
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
@@ -804,7 +874,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Att">
|
<Row label="Att">
|
||||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label={t('icmp.filter')}>
|
<Row label={t('icmp.filter')}>
|
||||||
|
|||||||
@@ -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,9 +43,20 @@ 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
|
||||||
@@ -54,7 +66,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
|
|||||||
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}' },
|
||||||
@@ -64,6 +76,24 @@ function loadBasemap(): BasemapKey {
|
|||||||
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.
|
||||||
|
function addBasemap(
|
||||||
|
m: L.Map,
|
||||||
|
key: BasemapKey,
|
||||||
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
) {
|
||||||
|
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 opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||||
|
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -97,17 +127,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();
|
||||||
@@ -115,7 +154,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
|
||||||
@@ -123,14 +167,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;
|
||||||
@@ -224,9 +337,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
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, zoomSignal]);
|
}, [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);
|
||||||
|
|
||||||
@@ -267,6 +385,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
|
||||||
@@ -294,7 +427,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -14,12 +14,14 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
import {
|
||||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
WorkedBefore,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
import { netctl } from '@/../wailsjs/go/models';
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -40,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onLogged?: () => void;
|
onLogged?: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
bands?: string[];
|
bands?: string[];
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
@@ -58,14 +74,74 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
|
|
||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
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.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
const [contact, setContact] = useState<Station>(emptyStation());
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const activeGrid = useRef<any>(null);
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
|
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 isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
@@ -90,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch { /* ignore */ }
|
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(() => { refreshNets(); }, [refreshNets]);
|
||||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
@@ -145,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// 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) {
|
async function deactivate(id?: number) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
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).
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
@@ -173,6 +324,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
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,
|
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)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
finally { setLooking(false); }
|
finally { setLooking(false); }
|
||||||
@@ -196,20 +350,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
|
||||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
|
||||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
@@ -258,29 +401,81 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<RecentQSOsGrid
|
||||||
<AgGridReact<QSOForm>
|
rows={activeOrdered}
|
||||||
ref={activeGrid}
|
total={activeOrdered.length}
|
||||||
theme={hamlogTheme}
|
storageKey="net.onair"
|
||||||
rowData={active}
|
selectRowSignal={selectRow}
|
||||||
columnDefs={activeCols}
|
rowDragCall
|
||||||
defaultColDef={defaultColDef}
|
passOrder
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
animateRows={false}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
onRowSelected={setSelectedActiveIds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{isOpen && active.length > 0 && (
|
{isOpen && active.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<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]"
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* NET USERS / roster (right) */}
|
{/* NET USERS / roster (right) */}
|
||||||
@@ -289,7 +484,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.netUsersRoster')}
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Station>
|
<AgGridReact<Station>
|
||||||
ref={rosterGrid}
|
ref={rosterGrid}
|
||||||
@@ -298,6 +493,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
getRowId={(p) => String((p.data as any).callsign)}
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
|
|
||||||
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 = [
|
||||||
@@ -54,12 +54,14 @@ const QSL_STATUSES = [
|
|||||||
{ v: 'I', label: 'qslm.ignore' },
|
{ v: 'I', label: 'qslm.ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
// QSL routing methods for the paper-QSL route dropdowns, one per direction.
|
||||||
|
// These are the ADIF QSL Via enumeration (B/D/E) and go to QSL_SENT_VIA and
|
||||||
|
// QSL_RCVD_VIA — NOT to QSL_VIA, which holds the manager's callsign.
|
||||||
const QSL_VIA_OPTIONS = [
|
const QSL_VIA_OPTIONS = [
|
||||||
{ v: '_', label: 'qslm.leave' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
{ v: 'B', label: 'qslm.viaBureau' },
|
||||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
{ v: 'D', label: 'qslm.viaDirect' },
|
||||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
{ v: 'E', label: 'qslm.viaElectronic' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Maps a service value → its i18n label key (only for services with
|
// Maps a service value → its i18n label key (only for services with
|
||||||
@@ -120,6 +122,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||||
|
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||||
|
// bleed its log into the next one.
|
||||||
|
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
@@ -145,7 +151,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [qslSent, setQslSent] = useState('_');
|
const [qslSent, setQslSent] = useState('_');
|
||||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
const [qslSentDate, setQslSentDate] = useState('');
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
const [qslVia, setQslVia] = useState('_');
|
const [qslVia, setQslVia] = useState('_'); // how the card was SENT
|
||||||
|
const [qslRcvdVia, setQslRcvdVia] = useState('_'); // how it CAME BACK
|
||||||
const [qslNotes, setQslNotes] = useState('');
|
const [qslNotes, setQslNotes] = useState('');
|
||||||
const [qslComment, setQslComment] = useState('');
|
const [qslComment, setQslComment] = useState('');
|
||||||
|
|
||||||
@@ -177,6 +184,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
sent_date: ymd(qslSentDate),
|
sent_date: ymd(qslSentDate),
|
||||||
rcvd_date: ymd(qslRcvdDate),
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
via: qslVia === '_' ? '' : qslVia,
|
via: qslVia === '_' ? '' : qslVia,
|
||||||
|
rcvd_via: qslRcvdVia === '_' ? '' : qslRcvdVia,
|
||||||
notes: qslNotes,
|
notes: qslNotes,
|
||||||
comment: qslComment,
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -208,18 +216,27 @@ 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 serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
@@ -231,9 +248,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -353,6 +371,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<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">
|
||||||
{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}
|
{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}
|
||||||
@@ -371,6 +405,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -400,6 +435,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
// drives which QSOs the apply-form below updates; a search selects all.
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
// Its OWN column layout. Without a storageKey this fell back to
|
||||||
|
// the main log's key, so every resize or hidden column here
|
||||||
|
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||||
|
storageKey="qslmgr.paper"
|
||||||
rows={paperRows as any}
|
rows={paperRows as any}
|
||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
@@ -491,6 +530,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<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-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</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-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</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_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>
|
: 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>
|
||||||
@@ -504,6 +544,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
storageKey="qslmgr.upload"
|
||||||
rows={rows as any}
|
rows={rows as any}
|
||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
@@ -516,6 +557,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{/* Paper-QSL apply form */}
|
{/* Paper-QSL apply form */}
|
||||||
{service === 'paper' && (
|
{service === 'paper' && (
|
||||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
|
{/* Each direction carries its own route, and each sits with the status
|
||||||
|
and date it belongs to. A stack confirmed in one go usually arrived
|
||||||
|
the same way — a bureau delivery is exactly that — which is why the
|
||||||
|
route belongs on this form and not one QSO at a time in the editor. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
@@ -524,6 +569,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||||
|
<Select value={qslRcvdVia} onValueChange={setQslRcvdVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.rcvdViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -534,15 +583,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
|
||||||
<Select value={qslVia} onValueChange={setQslVia}>
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-32" title={t('qslm.sentViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -35,8 +36,14 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
|
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||||
|
// case where it already fits.
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||||
|
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -49,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [menu, onClose]);
|
||||||
|
|
||||||
|
// Position AFTER measuring.
|
||||||
|
//
|
||||||
|
// The menu used to be clamped against a guessed height — 230 px when the
|
||||||
|
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||||
|
// a selection, so right-clicking near the bottom of the window cut half the
|
||||||
|
// entries off. Its height also depends on which actions the caller passes, so
|
||||||
|
// no constant can be right for long.
|
||||||
|
//
|
||||||
|
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||||
|
// and failing that pinned to the top with its own scrollbar.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menu || !boxRef.current) return;
|
||||||
|
const r = boxRef.current.getBoundingClientRect();
|
||||||
|
const pad = 6;
|
||||||
|
let x = menu.x;
|
||||||
|
let y = menu.y;
|
||||||
|
if (x + r.width > window.innerWidth - pad) {
|
||||||
|
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||||
|
}
|
||||||
|
if (y + r.height > window.innerHeight - pad) {
|
||||||
|
// Above the cursor, if there is room there.
|
||||||
|
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||||
|
}
|
||||||
|
setPos({ x, y });
|
||||||
|
}, [menu, onSendTo]);
|
||||||
|
|
||||||
if (!menu) return null;
|
if (!menu) return null;
|
||||||
const n = menu.ids.length;
|
const n = menu.ids.length;
|
||||||
// Keep the menu on-screen near the cursor.
|
|
||||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
|
||||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
ref={boxRef}
|
||||||
style={{ left: x, top: y }}
|
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||||
|
style={{ left: pos.x, top: pos.y }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
@@ -137,6 +168,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -1,6 +1,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';
|
||||||
@@ -21,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
@@ -36,12 +38,17 @@ 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).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
|
// V is in the list because the log HOLDS it: a LoTW download writes "verified"
|
||||||
|
// rather than "yes". A dropdown without it renders a verified contact as blank,
|
||||||
|
// which reads as "nothing recorded" — and the operator's next click would replace
|
||||||
|
// the strongest confirmation they have with whatever they picked instead.
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
{ value: '_', label: 'qedit.qslDash' },
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
{ value: 'Y', label: 'qedit.qslYes' },
|
{ value: 'Y', label: 'qedit.qslYes' },
|
||||||
|
{ value: 'V', label: 'qedit.qslVerified' },
|
||||||
{ value: 'N', label: 'qedit.qslNo' },
|
{ value: 'N', label: 'qedit.qslNo' },
|
||||||
{ value: 'R', label: 'qedit.qslRequested' },
|
{ value: 'R', label: 'qedit.qslRequested' },
|
||||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||||
@@ -55,10 +62,12 @@ type ConfDef = {
|
|||||||
key: string; label: string;
|
key: string; label: string;
|
||||||
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||||
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||||
via?: keyof QSOForm;
|
// How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
|
||||||
|
// only — the electronic channels below ARE the route.
|
||||||
|
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
|
||||||
};
|
};
|
||||||
const CONFIRMATIONS: ConfDef[] = [
|
const CONFIRMATIONS: ConfDef[] = [
|
||||||
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
|
||||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
@@ -71,6 +80,11 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
|
// the channel picker and the status table alongside the rest.
|
||||||
|
const OPSLOG_CONF = 'OPSLOG';
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -80,11 +94,28 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
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' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
// One colour per state, and each colour means something:
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground'
|
// Yes green — confirmed, the thing you wanted
|
||||||
: v === 'R' ? 'bg-warning text-warning-foreground'
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
: v === 'I' ? 'bg-muted-foreground text-background'
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
: 'bg-warning text-warning-foreground';
|
// 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +166,56 @@ 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();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -147,6 +228,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
|
||||||
|
// it is not offered here — a file that arrives carrying it keeps it, and this
|
||||||
|
// list is what an operator may choose.
|
||||||
|
const QSL_VIA_CHOICES = [
|
||||||
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
|
{ value: 'B', label: 'qedit.viaBureau' },
|
||||||
|
{ value: 'D', label: 'qedit.viaDirect' },
|
||||||
|
{ value: 'E', label: 'qedit.viaElectronic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||||
@@ -163,6 +266,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') }
|
||||||
@@ -187,6 +297,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.
|
||||||
@@ -207,6 +320,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 */ }
|
||||||
})
|
})
|
||||||
@@ -256,19 +370,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
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.
|
||||||
|
const r: any = await LookupCallsignFresh(call);
|
||||||
|
// 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,
|
||||||
@@ -327,7 +450,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +468,29 @@ 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>{t('qedit.title')}</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>
|
||||||
@@ -383,14 +526,29 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{/* Top: Callsign + RST + Fetch */}
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<div className="flex items-end gap-2 mb-3">
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<div className="flex items-center">
|
||||||
<Label>{t('qedit.callsign')}</Label>
|
<Label>{t('qedit.callsign')}</Label>
|
||||||
|
{(draft.callsign ?? '').trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||||
|
onClick={() => {
|
||||||
|
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||||
|
>
|
||||||
|
QRZ ↗
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<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={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -400,7 +558,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
{/* ── Left column ── */}
|
{/* ── Left column ── */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
|
||||||
|
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
@@ -466,8 +625,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||||||
|
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||||||
|
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -488,7 +649,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
|
||||||
|
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
|
||||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
@@ -498,8 +660,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
|
||||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
belongs with the other confirmation channels — see the QSL
|
||||||
|
Info tab. */}
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.qslMsg')}</Label>
|
||||||
|
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{/* The manager, and only the manager — ADIF QSL_VIA. How the
|
||||||
|
card travelled is a separate field, on the QSL Info tab. */}
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.qslVia')}</Label>
|
||||||
|
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -546,9 +719,51 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Label>{t('qedit.manageConf')}</Label>
|
<Label>{t('qedit.manageConf')}</Label>
|
||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
<SelectContent>
|
||||||
|
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
||||||
|
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||||||
|
QSO columns, and this channel lives in the ADIF
|
||||||
|
extras. It gets its own editor below rather than the
|
||||||
|
generic sent/received/date grid, which has no field
|
||||||
|
to bind to. */}
|
||||||
|
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confSel === OPSLOG_CONF ? (
|
||||||
|
/* OpsLog's own card. "Sent" is stamped when the card
|
||||||
|
actually goes out, so it is shown, not offered: ticking
|
||||||
|
it by hand would record something that never happened.
|
||||||
|
"Received" drives the PSE/TNX stamp printed on the card,
|
||||||
|
which is the whole reason the flag exists — hence the
|
||||||
|
live indicator next to it. It writes immediately rather
|
||||||
|
than on Save, because clearing an extras key would not
|
||||||
|
survive the merge Save does. */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.sent')}</Label>
|
||||||
|
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.received')}</Label>
|
||||||
|
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
|
||||||
|
{t('qedit.qslReceived')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
<div><Label>{t('qedit.received')}</Label>
|
<div><Label>{t('qedit.received')}</Label>
|
||||||
@@ -556,20 +771,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
? <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>{t('qedit.dateSent')}</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>{t('qedit.dateReceived')}</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>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
enumeration. Not the manager: that is a callsign
|
||||||
|
and lives on the Contact's details tab. */}
|
||||||
|
{def.sentVia && (
|
||||||
|
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
|
||||||
|
)}
|
||||||
|
{def.rcvdVia && (
|
||||||
|
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel */}
|
{/* Right: live status grid for every channel.
|
||||||
<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">{t('qedit.thType')}</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
@@ -580,11 +807,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : 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>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
|
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||||
|
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -27,11 +31,36 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
|
|||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
||||||
|
// that carry no my_grid of their own.
|
||||||
|
myGrid?: string;
|
||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
|
selectRowSignal?: { id: number; seq: number };
|
||||||
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||||
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
|
// register external drop zones via api.addRowDropZone.
|
||||||
|
rowDragCall?: boolean;
|
||||||
|
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||||
|
// is the display order — a pinned "#" column numbers it, and all column
|
||||||
|
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||||
|
// by a header click. The parent owns/reorders the array.
|
||||||
|
passOrder?: boolean;
|
||||||
|
onGridApi?: (api: any) => void;
|
||||||
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
|
storageKey?: string;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
|
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||||
|
// entry form is empty, so browsing the log answers "what have I got with this
|
||||||
|
// one?" without retyping the call.
|
||||||
|
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
@@ -40,16 +69,23 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||||
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||||
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||||
|
onFilteredCountChange?: (count: number | null) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
|
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||||
|
rowColors?: RowColorSettings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
|
|
||||||
function fmtMhzDots(hz?: number): string {
|
function fmtMhzDots(hz?: number): string {
|
||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
@@ -58,23 +94,13 @@ function fmtMhzDots(hz?: number): string {
|
|||||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both formatters follow the display preference (Settings → General). What is
|
||||||
|
// STORED never changes — see lib/dateFormat.
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return s;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateOnly(s);
|
||||||
const t = String(s).trim();
|
|
||||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
|
||||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
||||||
const d = new Date(t);
|
|
||||||
if (isNaN(d.getTime())) return t;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
@@ -88,8 +114,31 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
|||||||
// hooks can't run at module level, so the component calls this with its own t.
|
// hooks can't run at module level, so the component calls this with its own t.
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
||||||
|
//
|
||||||
|
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
||||||
|
// portable outings, so the station the QSO was made from is not necessarily the
|
||||||
|
// one configured today. `myGrid` (the current profile) is only the fallback for
|
||||||
|
// the many older records that carry no my_* fields at all.
|
||||||
|
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||||
|
if (!d) return undefined;
|
||||||
|
const here =
|
||||||
|
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
||||||
|
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
||||||
|
(myGrid ? gridToLatLon(myGrid) : null);
|
||||||
|
const there =
|
||||||
|
(d.grid && gridToLatLon(d.grid)) ||
|
||||||
|
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
||||||
|
if (!here || !there) return undefined;
|
||||||
|
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
|
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||||
|
// so it does not change when a filter narrows what is shown.
|
||||||
|
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||||
@@ -123,45 +172,54 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
|
// the cluster grid's own distance column.
|
||||||
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── QSL ──
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||||
|
|
||||||
// ── LoTW ──
|
// ── LoTW ──
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── eQSL ──
|
// ── eQSL ──
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
|
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||||
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
@@ -203,6 +261,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||||
@@ -226,14 +291,40 @@ const GRP_KEYS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
|
||||||
|
// by AG Grid's saved column state: dropping `hide` here (on both save and
|
||||||
|
// restore) stops a stale saved state from re-hiding a shown award column on
|
||||||
|
// every awardCols rebuild — the desync that made award columns vanish
|
||||||
|
// mid-session.
|
||||||
|
//
|
||||||
|
// Their POSITION, however, has to stay in the state. Filtering the entries out
|
||||||
|
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
|
||||||
|
// AG Grid appends what the state doesn't mention — so a column the operator
|
||||||
|
// dragged to sit AFTER an award column jumped back in front of it on reload.
|
||||||
|
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
|
(st ?? []).map((s) => {
|
||||||
|
if (!String(s?.colId ?? '').startsWith('award_')) return s;
|
||||||
|
const { hide: _hide, ...rest } = s as any;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||||
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||||
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
// Rebuild the columns when the format changes: the formatters are captured
|
||||||
|
// inside the column definitions, so nothing else would notice.
|
||||||
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -262,68 +353,221 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before the
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
// column events) and cleared by the re-apply effect below.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Award-column WIDTHS are persisted separately too, for the same reason as
|
||||||
|
// visibility: award columns are stripped from AG Grid's saved column-state
|
||||||
|
// round-trip, so their width would otherwise reset to the default on reopen.
|
||||||
|
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
||||||
|
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
||||||
|
const awardWidthsRef = useRef<Record<string, number>>({});
|
||||||
|
const awardWidthsInit = useRef(false);
|
||||||
|
if (!awardWidthsInit.current) {
|
||||||
|
awardWidthsInit.current = true;
|
||||||
|
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
||||||
|
// apply them to any award columns already on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
||||||
|
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
||||||
|
if (!remote || !remote.length) return;
|
||||||
|
for (const it of remote as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
seedLocal(AWARD_WIDTH_KEY, remote);
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (api && awardCols?.length) {
|
||||||
|
const ups = awardCols
|
||||||
|
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
||||||
|
.filter((u) => !!u.newWidth);
|
||||||
|
if (ups.length) api.setColumnWidths(ups);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||||
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
|
col.rowDrag = true;
|
||||||
|
}
|
||||||
|
if (passOrder) {
|
||||||
|
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||||
|
col.sortable = false;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
});
|
});
|
||||||
|
if (passOrder) {
|
||||||
|
base.unshift({
|
||||||
|
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||||
|
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||||
|
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||||
|
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||||
|
} as ColDef<QSOForm>);
|
||||||
|
}
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
||||||
|
|
||||||
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
const widthUps: { key: string; newWidth: number }[] = [];
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
||||||
|
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
||||||
|
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
||||||
|
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
||||||
|
}
|
||||||
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
|
// No column filters here, deliberately. This grid holds only the most recent
|
||||||
|
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||||
|
// an empty result then reads as "the log does not contain it" rather than "it
|
||||||
|
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||||
|
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||||
|
//
|
||||||
|
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||||
|
// Removing the filter also gives each header back the width of its menu icon.
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: !passOrder,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
filter: true,
|
filter: false,
|
||||||
suppressMovable: false,
|
suppressMovable: false,
|
||||||
}), []);
|
}), [passOrder]);
|
||||||
|
|
||||||
|
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||||
|
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||||
|
const sanitizeState = useCallback((state: any[]) => (
|
||||||
|
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||||
|
), [passOrder]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
onGridApi?.(e.api);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
const local = loadLocal(colStateKey);
|
||||||
|
if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||||
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
|
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||||
|
// row node across a reorder (getRowId by id), so the number is cached and
|
||||||
|
// stale until we force it. Refresh it on every model update (a reorder
|
||||||
|
// fires one). force:true bypasses the value cache.
|
||||||
|
if (passOrder && api?.refreshCells) {
|
||||||
|
api.refreshCells({ columns: ['__order'], force: true });
|
||||||
|
}
|
||||||
|
if (!api || !onFilteredCountChange) return;
|
||||||
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
|
}, [onFilteredCountChange, passOrder]);
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (!state) return;
|
||||||
}, []);
|
saveState(colStateKey, sanitizeAwardCols(state));
|
||||||
|
// Award columns are stripped above, so persist their widths on the side.
|
||||||
|
let changed = false;
|
||||||
|
for (const s of state) {
|
||||||
|
const id = String((s as any)?.colId ?? '');
|
||||||
|
if (id.startsWith('award_') && (s as any).width) {
|
||||||
|
const code = id.slice('award_'.length).toUpperCase();
|
||||||
|
if (awardWidthsRef.current[code] !== (s as any).width) {
|
||||||
|
awardWidthsRef.current[code] = (s as any).width;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
||||||
|
}
|
||||||
|
}, [colStateKey, AWARD_WIDTH_KEY]);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||||
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||||
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||||
|
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||||
|
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||||
|
// set, so every column change for the rest of the session was silently
|
||||||
|
// discarded and the layout reverted on reload. Any future dependency of the
|
||||||
|
// memo is now covered for free.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const api = gridRef.current?.api;
|
||||||
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
|
setSelCount(sel.length);
|
||||||
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
|
onRowSelectedQso?.(sel[0] ?? null);
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -331,15 +575,42 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
gridRef.current?.api?.selectAll();
|
gridRef.current?.api?.selectAll();
|
||||||
}, [selectAllSignal]);
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||||
|
// tick so a row-data refresh in the same render settles first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.deselectAll();
|
||||||
|
api.forEachNode((n: any) => {
|
||||||
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [selectRowSignal?.seq]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -367,11 +638,40 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{/* Live selection count — visible without opening the context menu. */}
|
||||||
|
{selCount > 0 && (
|
||||||
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||||
|
{t('rqg.selectedCount', { n: selCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Select every loaded row that passes the active column filters — so a
|
||||||
|
filtered view can be selected in one click (then send to LoTW, bulk
|
||||||
|
edit, export…). Once everything is selected the same button flips to
|
||||||
|
"Unselect all". */}
|
||||||
|
{(() => {
|
||||||
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const api: any = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (allSelected) api.deselectAll();
|
||||||
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||||
|
else api.selectAll('filtered');
|
||||||
|
}}>
|
||||||
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('rqg.clearFiltersTitle')}>
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
@@ -390,18 +690,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
onCellContextMenu={onCellContextMenu}
|
onCellContextMenu={onCellContextMenu}
|
||||||
preventDefaultOnContextMenu
|
preventDefaultOnContextMenu
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
|
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,6 +721,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
@@ -463,8 +768,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<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">{t('rqg.grpAwards')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -100,7 +103,34 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-2 min-h-0">
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
|
sibling of the <svg> landed beside it. */}
|
||||||
|
<div className="flex flex-col items-center justify-center p-2 min-h-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
@@ -160,6 +190,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
); })}
|
); })}
|
||||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Short and long path to the DX, in figures.
|
||||||
|
The bezel already carries the short path as a red marker, but a
|
||||||
|
marker is a direction, not a number — the same pair sits in the
|
||||||
|
status bar at 10px and operators reported not being able to read it.
|
||||||
|
Here because it belongs to the compass: every place that draws one
|
||||||
|
gets the readout, instead of each caller inventing its own.
|
||||||
|
Clickable when the caller can turn, like the status bar's. */}
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { SpellCheck, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type ScpResult = { partial?: string[]; nplus1?: string[] };
|
||||||
|
|
||||||
|
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
|
||||||
|
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
|
||||||
|
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
|
||||||
|
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
|
||||||
|
result: ScpResult;
|
||||||
|
currentCall: string;
|
||||||
|
count: number; // master-list size (0 = list not downloaded yet)
|
||||||
|
onPick: (call: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const cur = (currentCall || '').trim().toUpperCase();
|
||||||
|
const partial = result.partial ?? [];
|
||||||
|
const nplus1 = result.nplus1 ?? [];
|
||||||
|
|
||||||
|
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||||
|
{calls.length === 0 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
|
||||||
|
) : calls.map((c) => (
|
||||||
|
<button key={c} type="button" onClick={() => onPick(c)}
|
||||||
|
title={t('scp.fill', { call: c })}
|
||||||
|
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
|
||||||
|
c === cur ? 'bg-success/20 text-success font-bold'
|
||||||
|
: 'hover:bg-primary/15 text-foreground/90')}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{count === 0 ? (
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
|
||||||
|
{t('scp.noList')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
|
||||||
|
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
|
||||||
|
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,32 @@ export interface RecentSpotQSO {
|
|||||||
freqKHz: number;
|
freqKHz: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
|
// Award references logged for this QSO, each one complete and ready to read
|
||||||
|
// on a cluster line ("POTA FR-11553"). Empty for an ordinary contact.
|
||||||
|
refs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes
|
||||||
|
// truncate what does not fit without saying so. Only the comment BUILT here is
|
||||||
|
// held to it — whatever the operator types is their own business.
|
||||||
|
const SPOT_COMMENT_MAX = 30;
|
||||||
|
|
||||||
|
// spotComment puts the award references after the mode, the order a cluster
|
||||||
|
// line is read in: "CW POTA FR-11553".
|
||||||
|
//
|
||||||
|
// A reference goes in whole or not at all. Truncation would leave "CW POTA" or
|
||||||
|
// half a park number on the air, which is worse than saying nothing: a spot
|
||||||
|
// nobody can act on still costs everyone who reads it.
|
||||||
|
export function spotComment(mode: string, refs?: string[]): string {
|
||||||
|
let out = (mode || '').trim();
|
||||||
|
for (const ref of refs ?? []) {
|
||||||
|
const one = ref.trim();
|
||||||
|
if (!one) continue;
|
||||||
|
const next = out ? `${out} ${one}` : one;
|
||||||
|
if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit
|
||||||
|
out = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +49,9 @@ interface Props {
|
|||||||
defaultCall: string;
|
defaultCall: string;
|
||||||
defaultFreqKHz: number;
|
defaultFreqKHz: number;
|
||||||
defaultMode: string;
|
defaultMode: string;
|
||||||
|
// Award references on the QSO being entered (or on the last logged one),
|
||||||
|
// appended to the comment after the mode.
|
||||||
|
defaultRefs?: string[];
|
||||||
// Master cluster name, shown so the user knows where the spot goes.
|
// Master cluster name, shown so the user knows where the spot goes.
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
recent: RecentSpotQSO[];
|
recent: RecentSpotQSO[];
|
||||||
@@ -33,7 +62,7 @@ interface Props {
|
|||||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||||
// contact into the form.
|
// contact into the form.
|
||||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, defaultRefs, targetName, recent, onSend }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [call, setCall] = useState('');
|
const [call, setCall] = useState('');
|
||||||
const [freqKHz, setFreqKHz] = useState('');
|
const [freqKHz, setFreqKHz] = useState('');
|
||||||
@@ -48,12 +77,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCall((defaultCall || '').toUpperCase());
|
setCall((defaultCall || '').toUpperCase());
|
||||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||||
setMessage(defaultMode || '');
|
setMessage(spotComment(defaultMode, defaultRefs));
|
||||||
setError('');
|
setError('');
|
||||||
setOk(false);
|
setOk(false);
|
||||||
// Focus the freq if the call is already known, else the call.
|
// Focus the freq if the call is already known, else the call.
|
||||||
setTimeout(() => callRef.current?.focus(), 50);
|
setTimeout(() => callRef.current?.focus(), 50);
|
||||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
}, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const c = call.trim().toUpperCase();
|
const c = call.trim().toUpperCase();
|
||||||
@@ -77,7 +106,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
function pick(q: RecentSpotQSO) {
|
function pick(q: RecentSpotQSO) {
|
||||||
setCall(q.callsign.toUpperCase());
|
setCall(q.callsign.toUpperCase());
|
||||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||||
if (q.mode) setMessage(q.mode);
|
setMessage(spotComment(q.mode, q.refs));
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,914 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
|
import {
|
||||||
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
|
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
GetAmpStatuses, GetFlexState,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string; type: string; name: string; host: string;
|
||||||
|
user?: string; pass?: string; channels?: number; labels: string[];
|
||||||
|
// Generic HTTP board only. The per-relay URLs win over the patterns.
|
||||||
|
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string;
|
||||||
|
};
|
||||||
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
|
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||||
|
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||||
|
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||||
|
// screen; aligned rows are what an operator expects from a dashboard.
|
||||||
|
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||||
|
const GRID_GAP = 16; // px between cards, both axes
|
||||||
|
|
||||||
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2, httpgen: 4 };
|
||||||
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay', httpgen: 'HTTP relay' };
|
||||||
|
|
||||||
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
|
// chanCount is the relay count for a device. It MUST agree with
|
||||||
|
// deviceRelayCount in app.go: the two decide how many labels and URL rows the
|
||||||
|
// editor shows and how many relays the driver is built for, and a type known to
|
||||||
|
// one and not the other silently pins the count to its fixed default — which is
|
||||||
|
// exactly what happened when the HTTP board was added here and not below.
|
||||||
|
const chanCount = (d: Device): number =>
|
||||||
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||||
|
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||||
|
: d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4)
|
||||||
|
: (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
|
function blankDevice(): Device {
|
||||||
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||||
|
|
||||||
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
|
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||||
|
// passed in (so the panel can also order this widget); it drives the shared
|
||||||
|
// rotator backend.
|
||||||
|
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||||
|
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const [goto, setGoto] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const turn = (az: number) => {
|
||||||
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
|
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const poll = refetch;
|
||||||
|
|
||||||
|
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||||
|
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">
|
||||||
|
<Compass className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||||
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex gap-4 items-start">
|
||||||
|
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||||
|
app carries it rather than each caller drawing its own. */}
|
||||||
|
<RotorCompass
|
||||||
|
bearing={bearing ?? null}
|
||||||
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
|
centerLat={centerLat ?? null}
|
||||||
|
centerLon={centerLon ?? null}
|
||||||
|
rotorEnabled={hd.ok}
|
||||||
|
rotors={hd.rotors}
|
||||||
|
activeRotor={hd.active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
|
onGoto={(az) => turn(az)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="font-mono">
|
||||||
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||||
|
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||||
|
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||||
|
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
|
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||||
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||||
|
const [editVal, setEditVal] = useState('');
|
||||||
|
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
const isUB = ant.type !== 'steppir';
|
||||||
|
|
||||||
|
const readLengths = useCallback(async () => {
|
||||||
|
setReading(true); setErr('');
|
||||||
|
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
}, []);
|
||||||
|
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||||
|
// +/- starts from the real values rather than blind.
|
||||||
|
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||||
|
|
||||||
|
// Send an absolute length to one element and remember it as the new baseline.
|
||||||
|
const setLen = async (i: number, mm: number) => {
|
||||||
|
const next = Math.max(0, Math.round(mm));
|
||||||
|
const prev = lengths[i] ?? 0;
|
||||||
|
setBusyEl(i); setErr('');
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||||
|
try { await MotorSetElement(i, next); refetch(); }
|
||||||
|
catch (e: any) {
|
||||||
|
// Rejected by the controller — most often the element is at its travel
|
||||||
|
// limit for this band (extending on a low band). Revert the optimistic
|
||||||
|
// value so the display stays truthful, and explain the likely cause when
|
||||||
|
// the failed move was an extension.
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||||
|
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
finally { setBusyEl(null); }
|
||||||
|
};
|
||||||
|
// Nudge one element by ±2 mm from its current known length.
|
||||||
|
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||||
|
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||||
|
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||||
|
const commitEdit = (i: number) => {
|
||||||
|
const v = parseInt(editVal, 10);
|
||||||
|
setEditingEl(null);
|
||||||
|
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||||
|
};
|
||||||
|
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<AntennaIcon className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||||
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||||
|
</div>
|
||||||
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{dirs.map(([d, lbl]) => (
|
||||||
|
<button key={d} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(SetUltrabeamDirection(d))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||||
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 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-1">{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-1 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.5 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.5 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. */}
|
||||||
|
<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.5 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.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUB && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||||
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||||
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lengths.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||||
|
up structural bytes past the real data (varying by band), which
|
||||||
|
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||||
|
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{editingEl === i ? (
|
||||||
|
<input autoFocus type="number" value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onBlur={() => commitEdit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||||
|
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||||
|
title={t('station.setExactLen')}
|
||||||
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||||
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||||
|
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
|
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||||
|
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||||
|
const [order, setOrder] = useState<string[]>(() => {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
});
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
|
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||||
|
// row so cards wrap onto further lines even when there's horizontal room.
|
||||||
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
|
||||||
|
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||||
|
// cards as the window allows; an explicit count is honoured as asked, even
|
||||||
|
// past that width — the operator can see his own screen.
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [colCount, setColCount] = useState(1);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||||
|
const el = gridRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [cols]);
|
||||||
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
|
// FlexRadio meters read live; the Flex state rides along because a PGXL's
|
||||||
|
// OPERATE/meters come from the radio, not the direct GSCP link.
|
||||||
|
const [amps, setAmps] = useState<any[]>([]);
|
||||||
|
const [flexState, setFlexState] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
|
||||||
|
// panel still get the controls. Re-read the enabled flag so it appears/hides
|
||||||
|
// without a restart.
|
||||||
|
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||||
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
|
} catch { /* ignore transient */ }
|
||||||
|
}, []);
|
||||||
|
const pollRot = useCallback(async () => {
|
||||||
|
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
const pollAnt = useCallback(async () => {
|
||||||
|
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
useEffect(() => {
|
||||||
|
poll(); pollRot(); pollAnt();
|
||||||
|
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [poll, pollRot, pollAnt, devices.length]);
|
||||||
|
|
||||||
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
|
// Reorder so `dragged` lands just before `target`.
|
||||||
|
const onDrop = (targetId: string) => {
|
||||||
|
const src = dragId.current; dragId.current = null;
|
||||||
|
if (!src || src === targetId) return;
|
||||||
|
const ids = widgetIds.filter((id) => id !== src);
|
||||||
|
const at = ids.indexOf(targetId);
|
||||||
|
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||||
|
persistOrder(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (next: Device[]) => {
|
||||||
|
setDevices(next);
|
||||||
|
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||||
|
await loadDevices();
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||||
|
const key = `${dev.id}:${relay}`;
|
||||||
|
setBusy((b) => ({ ...b, [key]: true }));
|
||||||
|
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||||
|
setStatus((st) => {
|
||||||
|
const d = st[dev.id]; if (!d) return st;
|
||||||
|
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||||
|
});
|
||||||
|
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||||
|
await poll();
|
||||||
|
setBusy((b) => ({ ...b, [key]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||||
|
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||||
|
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||||
|
|
||||||
|
// Build the widget list (rotator first by default, then devices), then order it
|
||||||
|
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||||
|
const deviceCard = (dev: Device) => {
|
||||||
|
const st = status[dev.id];
|
||||||
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<PlugZap className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||||
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
|
||||||
|
across the whole card — they wrap to fill the available width instead. */}
|
||||||
|
<div className="p-2 flex flex-wrap gap-1.5">
|
||||||
|
{relays.map((r) => {
|
||||||
|
const key = `${dev.id}:${r.number}`;
|
||||||
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
|
return (
|
||||||
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
title={label}
|
||||||
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
|
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||||
|
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
|
||||||
|
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||||||
|
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||||||
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||||
|
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||||
|
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||||
|
// full-width, and they need that room here too.
|
||||||
|
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||||
|
if (rot.enabled) {
|
||||||
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
|
}
|
||||||
|
if (ant.enabled) {
|
||||||
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
|
}
|
||||||
|
// One card per configured amplifier (identical to the Flex panel's card).
|
||||||
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||||
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||||
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||||
|
card can sit on a further line even with horizontal room to spare. */}
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
|
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||||
|
<button key={c} type="button"
|
||||||
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{c === 'auto' ? t('station.colsAuto') : c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noDevices && !editing && (
|
||||||
|
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('station.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||||
|
the tallest one on its row, so the rows line up instead of ending
|
||||||
|
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||||
|
need the room. Each card has a grip handle (left rail) as the drag
|
||||||
|
initiator, since the body is full of buttons. */}
|
||||||
|
<div ref={gridRef} style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||||
|
gap: `${GRID_GAP}px`,
|
||||||
|
}}>
|
||||||
|
{ordered.map((w) => (
|
||||||
|
<div key={w.id}
|
||||||
|
// A wide card takes two columns — but never more than there are, or
|
||||||
|
// the grid would grow a phantom column on a narrow window.
|
||||||
|
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||||
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
|
{/* h-full down the chain is what makes the card fill the row height
|
||||||
|
rather than sit at its natural size in a taller cell. */}
|
||||||
|
<div className="flex items-stretch h-full">
|
||||||
|
<div draggable
|
||||||
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
|
title={t('station.dragMove')}
|
||||||
|
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||||
|
dragId.current === w.id && 'opacity-60')}>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noDevices && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||||
|
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
|
const setType = (type: string) => {
|
||||||
|
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||||
|
const n = chanCount({ ...device, type, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, type, channels, labels });
|
||||||
|
};
|
||||||
|
const setChannels = (channels: number) => {
|
||||||
|
const n = chanCount({ ...device, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, channels, labels });
|
||||||
|
};
|
||||||
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDingtian = device.type === 'dingtian';
|
||||||
|
const isDenkovi = device.type === 'denkovi';
|
||||||
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
|
const isHTTPGen = device.type === 'httpgen';
|
||||||
|
// COM ports for the generic USB-serial relay picker.
|
||||||
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUsbRelay) return;
|
||||||
|
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||||
|
}, [isUsbRelay]);
|
||||||
|
// Detected FTDI serials for the Denkovi picker.
|
||||||
|
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [detectMsg, setDetectMsg] = useState('');
|
||||||
|
const detectDenkovi = async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
setDetectMsg('');
|
||||||
|
try {
|
||||||
|
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||||
|
setDenkoviSerials(list);
|
||||||
|
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||||
|
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||||
|
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||||
|
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||||
|
finally { setDetecting(false); }
|
||||||
|
};
|
||||||
|
// Connection test result for the current device config.
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const testDevice = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestMsg(null);
|
||||||
|
try {
|
||||||
|
const r: any = await TestStationDevice(device as any);
|
||||||
|
setTestMsg(r?.ok
|
||||||
|
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||||
|
: { ok: false, text: r?.error || t('station.testFail') });
|
||||||
|
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||||
|
finally { setTesting(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||||
|
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.type')}</Label>
|
||||||
|
<Select value={device.type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||||
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
|
<SelectItem value="httpgen">{t('station.typeHttpGen')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.name')}</Label>
|
||||||
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(isDenkovi || isUsbRelay || isDingtian || isHTTPGen) && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('station.channels')}</Label>
|
||||||
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDenkovi ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.ftdiSerial')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||||
|
list="denkovi-serials"
|
||||||
|
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<datalist id="denkovi-serials">
|
||||||
|
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||||
|
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
{t('station.detect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||||
|
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||||
|
</div>
|
||||||
|
) : isUsbRelay ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.comPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
|
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||||
|
<Label>{t('station.host')}</Label>
|
||||||
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||||||
|
</div>
|
||||||
|
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||||
|
"HTTP Session" is enabled in its web page, the password only when a
|
||||||
|
relay password is set. */}
|
||||||
|
{isDingtian && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtSession')}</Label>
|
||||||
|
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||||
|
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtPwd')}</Label>
|
||||||
|
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||||
|
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isKM && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.user')}</Label>
|
||||||
|
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.pass')}</Label>
|
||||||
|
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Generic HTTP board: a URL to switch each relay on and one to switch it
|
||||||
|
off. Two ways to fill it, and the per-relay one is why this type
|
||||||
|
exists — a hand-made switch often has URLs with nothing in common
|
||||||
|
between channels, which no pattern can express. */}
|
||||||
|
{isHTTPGen && (
|
||||||
|
<div className="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.onPattern')}</Label>
|
||||||
|
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=on"
|
||||||
|
value={device.on_pattern ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...device, on_pattern: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.offPattern')}</Label>
|
||||||
|
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=off"
|
||||||
|
value={device.off_pattern ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...device, off_pattern: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.perRelayUrls')}</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{device.labels.map((_, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">{i + 1}</span>
|
||||||
|
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
|
||||||
|
value={device.on_urls?.[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const on_urls = [...(device.on_urls ?? [])];
|
||||||
|
while (on_urls.length < device.labels.length) on_urls.push('');
|
||||||
|
on_urls[i] = e.target.value;
|
||||||
|
onChange({ ...device, on_urls });
|
||||||
|
}} />
|
||||||
|
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
|
||||||
|
value={device.off_urls?.[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const off_urls = [...(device.off_urls ?? [])];
|
||||||
|
while (off_urls.length < device.labels.length) off_urls.push('');
|
||||||
|
off_urls[i] = e.target.value;
|
||||||
|
onChange({ ...device, off_urls });
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.labels')}</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{device.labels.map((lab, i) => (
|
||||||
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
|
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{testMsg && (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||||
|
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Statistics dashboard.
|
||||||
|
//
|
||||||
|
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||||
|
// charts readable and colour-blind-safe):
|
||||||
|
//
|
||||||
|
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||||
|
// encodes identity, never a magnitude the bar's length already shows.
|
||||||
|
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||||
|
// the categories ARE the subject (the continent donut). The order is the
|
||||||
|
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||||
|
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||||
|
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||||
|
// makes them interesting. Close values belong in bars.
|
||||||
|
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||||
|
// a table view gives the WCAG-clean twin.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
|
type Stats = {
|
||||||
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
|
first_qso: string; last_qso: string;
|
||||||
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
|
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||||
|
// when that bucket would be too fine for the period (see the Go side).
|
||||||
|
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||||
|
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
|
||||||
|
// Period / contest metrics.
|
||||||
|
window_start: string; window_end: string; window_hours: number;
|
||||||
|
avg_per_hour: number; avg_per_active: number;
|
||||||
|
on_air_minutes: number; off_air_minutes: number;
|
||||||
|
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||||
|
gaps: Gap[]; rate: Bucket[];
|
||||||
|
rate_ops: string[]; rate_by_op: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||||
|
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||||
|
|
||||||
|
const CONTINENT_NAME: Record<string, string> = {
|
||||||
|
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||||
|
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||||
|
<header className="mb-3 shrink-0">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||||
|
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||||
|
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||||
|
// stays in high-contrast foreground ink so it reads on every theme.
|
||||||
|
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||||
|
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||||
|
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||||
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
|
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||||
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
|
const top = max ? data.slice(0, max) : data;
|
||||||
|
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{top.map((d, i) => (
|
||||||
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||||
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
|
{share && (
|
||||||
|
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||||
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
|
// information.
|
||||||
|
|
||||||
|
function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||||
|
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||||
|
// (the value is in the tooltip, and every number is in the table view).
|
||||||
|
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||||
|
return (
|
||||||
|
// The container includes the x-axis band — a fixed height that excludes it is
|
||||||
|
// how cards end up with a tiny nested scrollbar.
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
|
||||||
|
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||||
|
{nf(d.count)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||||
|
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
|
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||||
|
// when the filter changes is a chart nobody can trust.
|
||||||
|
|
||||||
|
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||||
|
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||||
|
}) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||||
|
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||||
|
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{rate.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||||
|
{/* The column is the hour's total; the segments are who made it. */}
|
||||||
|
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||||
|
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<div key={o} style={{
|
||||||
|
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||||
|
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||||
|
rate sheet below, so nothing is reachable by hover alone. */}
|
||||||
|
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||||
|
{hov !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||||
|
{' · '}{nf(rate[hov].count)} QSO
|
||||||
|
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!single && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||||
|
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||||
|
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||||
|
|
||||||
|
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||||
|
if (rate.length === 0) return null;
|
||||||
|
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||||
|
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||||
|
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||||
|
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map(({ d, i }) => (
|
||||||
|
<tr key={d.key} className="border-t border-border/50">
|
||||||
|
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||||
|
{ops.map((_, o) => (
|
||||||
|
<td key={o} className="px-2 py-0.5 text-right">
|
||||||
|
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="border-t border-border font-semibold">
|
||||||
|
<td className="px-2 py-1">Total</td>
|
||||||
|
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||||
|
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||||
|
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||||
|
// is chaos and goes unread.
|
||||||
|
|
||||||
|
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||||
|
const y = (v: number) => H - (v / peak) * H;
|
||||||
|
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||||
|
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||||
|
const h = hover ?? -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||||
|
onMouseLeave={() => setHover(null)}>
|
||||||
|
{/* Recessive hairline grid — solid, never dashed. */}
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||||
|
))}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill="url(#statsArea)" />
|
||||||
|
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{h >= 0 && (
|
||||||
|
<>
|
||||||
|
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||||
|
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||||
|
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||||
|
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{h >= 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||||
|
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||||
|
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||||
|
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
<span>{data[0].key}</span>
|
||||||
|
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||||
|
<span>{data[data.length - 1].key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||||
|
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||||
|
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||||
|
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||||
|
// donut would hide precisely the fact that makes them interesting).
|
||||||
|
//
|
||||||
|
// The categories ARE the subject here, so this is the one chart using the
|
||||||
|
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||||
|
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||||
|
|
||||||
|
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||||
|
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||||
|
let acc = 0;
|
||||||
|
const arcs = slots.map((d, i) => {
|
||||||
|
const frac = d.count / total;
|
||||||
|
// A 2px surface gap between segments — the correct separator, not a border.
|
||||||
|
const len = Math.max(0, frac * C - 2);
|
||||||
|
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||||
|
acc += frac;
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||||
|
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||||
|
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||||
|
className="transition-[stroke-width] duration-150 cursor-default"
|
||||||
|
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
{hov === null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||||
|
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
{slots.map((d, i) => (
|
||||||
|
<li key={d.key}
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||||
|
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||||
|
hov === i && 'bg-muted')}>
|
||||||
|
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||||
|
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||||
|
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{((d.count / total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||||
|
|
||||||
|
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||||
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-[11px] tabular-nums">
|
||||||
|
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||||
|
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period ───────────────────────────────────────────────────────────────────
|
||||||
|
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||||
|
// the charts would each show a different slice of time.
|
||||||
|
|
||||||
|
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||||
|
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||||
|
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||||
|
const now = new Date();
|
||||||
|
switch (p) {
|
||||||
|
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||||
|
case 'y12': {
|
||||||
|
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'd30': {
|
||||||
|
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'custom': return [from, to];
|
||||||
|
default: return ['', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||||
|
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||||
|
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||||
|
// a year up top left this chart showing the last 7 days.
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
type Gran = typeof ACT_GRAN[number]['key'];
|
||||||
|
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||||
|
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||||
|
const seriesFor = (k: Gran): Bucket[] => (
|
||||||
|
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||||
|
// series empty when it would be too fine for the period, so "the first
|
||||||
|
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||||
|
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||||
|
// A bucket the user forces but that is too fine for the period falls back to
|
||||||
|
// the next coarser one rather than showing an empty chart.
|
||||||
|
let shown: Gran = g === 'auto' ? auto : g;
|
||||||
|
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||||
|
const series = seriesFor(shown);
|
||||||
|
const steppedUp = g !== 'auto' && shown !== g;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||||
|
<button type="button" onClick={() => setG('auto')}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('stats.granAuto')}
|
||||||
|
</button>
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{steppedUp && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||||
|
{series.length > 60
|
||||||
|
? <AreaTrend data={series} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||||
|
// a different question from "how much lately" and so gets its own card. The hour
|
||||||
|
// histogram used to cover a single day, too little to read anything from.
|
||||||
|
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||||
|
<button key={k} type="button" onClick={() => setG(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(tk)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{data.map((d) => (
|
||||||
|
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||||
|
const [period, setPeriod] = useState<Period>('all');
|
||||||
|
const [from, setFrom] = useState('');
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||||
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
|
const [contest, setContest] = useState('');
|
||||||
|
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
|
||||||
|
const [operators, setOperators] = useState<string[]>([]);
|
||||||
|
const [operator, setOperator] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
|
||||||
|
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||||
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
|
// filters fighting over the same axis.
|
||||||
|
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||||
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
|
||||||
|
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||||
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
|
// once, here, rather than guarding at every use site and missing one.
|
||||||
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
|
setStats({
|
||||||
|
...raw,
|
||||||
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
|
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||||
|
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||||
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
|
} as Stats);
|
||||||
|
}
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||||
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contest && period === 'custom' && !(from && to)) return;
|
||||||
|
load(period, from, to, contest, operator);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [period, from, to, contest, operator]);
|
||||||
|
|
||||||
|
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||||
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
|
// between two ordinary evenings — numbers that are true and completely useless.
|
||||||
|
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||||
|
// real operating session (which is exactly when an hourly rate chart exists).
|
||||||
|
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
const span = useMemo(() => {
|
||||||
|
if (!stats?.first_qso) return '';
|
||||||
|
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||||
|
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
if (busy && !stats) {
|
||||||
|
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const empty = t('stats.noData');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||||
|
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||||
|
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||||
|
|
||||||
|
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||||
|
never from a static list — so it can't offer a contest you never entered.
|
||||||
|
Choosing one supersedes the period: it brings its own window. */}
|
||||||
|
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||||
|
<option value="">{t('stats.noContest')}</option>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||||
|
{r.id} {r.year} — {nf(r.count)} QSO
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
|
||||||
|
continent split, rate) to one operator from the log. "—" = the
|
||||||
|
station owner (QSOs logged with no OPERATOR set). */}
|
||||||
|
{operators.length > 1 && (
|
||||||
|
<select value={operator} onChange={(e) => setOperator(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
|
||||||
|
title={t('stats.operatorTip')}>
|
||||||
|
<option value="">{t('stats.allOperators')}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||||
|
{([
|
||||||
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
|
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||||
|
] as [Period, string][]).map(([p, label], i) => (
|
||||||
|
<button key={p} onClick={() => setPeriod(p)}
|
||||||
|
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!contest && period === 'custom' && (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button onClick={() => setView('charts')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('table')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<Table2 className="size-3.5" />{t('stats.table')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||||
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
|
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||||
|
{windowed && stats.total > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||||
|
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||||
|
<div title={t('stats.avgWindowTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.avgActiveTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.best60Tip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||||
|
</div>
|
||||||
|
{/* On-air + off-air = the window, by construction. The first version
|
||||||
|
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||||
|
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||||
|
<div title={t('stats.onAirTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||||
|
{dur(stats.on_air_minutes)}
|
||||||
|
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats.rate.length > 1
|
||||||
|
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||||
|
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||||
|
{stats.gaps.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 min-w-0">
|
||||||
|
{stats.gaps.map((g, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||||
|
<span className="tabular-nums text-muted-foreground truncate">
|
||||||
|
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||||
|
style={{
|
||||||
|
background: 'var(--chart-seq-1)',
|
||||||
|
}}>
|
||||||
|
<span className="block h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||||
|
background: 'var(--chart-1)',
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||||
|
by operator. That is a table's job, not a chart's — and it's how
|
||||||
|
contesters actually read a run. Every value here is also what the
|
||||||
|
stacked chart above draws, so the two can never disagree. */}
|
||||||
|
{stats.rate.length > 1 && (
|
||||||
|
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||||
|
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'table' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||||
|
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||||
|
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||||
|
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||||
|
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||||
|
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||||
|
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||||
|
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||||
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
|
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||||
|
|
||||||
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
instead of truncating. */}
|
||||||
|
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||||
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||||
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||||
|
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||||
|
</Card>
|
||||||
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
|
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||||
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||||
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||||
|
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||||
|
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||||
|
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||||
|
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||||
|
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||||
|
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||||
|
// so they're the exact same size as the Flex/amp meters.
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||||
|
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||||
|
// antenna, matching the two rows of the native 4O3A app.
|
||||||
|
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||||
|
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const cls = ptt
|
||||||
|
? '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)]'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-extrabold text-sm">{letter}</span>
|
||||||
|
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||||
|
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||||
|
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||||
|
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||||
|
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
|
||||||
|
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
|
||||||
|
// between syllables as often as on a peak, so the raw reading collapses to zero
|
||||||
|
// several times a second mid-transmission and reads as a dropped link. The
|
||||||
|
// amplifier looked steady next to it only because it already smoothed this way.
|
||||||
|
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
|
||||||
|
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 connected = !!status.connected;
|
||||||
|
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||||
|
// below still smooths what is left between polls.
|
||||||
|
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||||
|
const fwdW = peakHold('fwd', rawFwdW);
|
||||||
|
|
||||||
|
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||||
|
// meaningless 1.00, which would wipe the figure the operator actually wants —
|
||||||
|
// the one measured while transmitting. So show the live value during TX, keep it
|
||||||
|
// briefly afterwards so it can be read, then let it go.
|
||||||
|
//
|
||||||
|
// That expiry is the point: the first version of this held the last TX value
|
||||||
|
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||||
|
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||||
|
// Same one-second window as the power peak above, so the two meters clear
|
||||||
|
// together.
|
||||||
|
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||||
|
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||||
|
const heldSwr = swrHold.current;
|
||||||
|
const vswr = rawFwdW >= 1
|
||||||
|
? rawVswr
|
||||||
|
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||||
|
? heldSwr.v
|
||||||
|
: undefined;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
const a: TGChannel = status.a ?? {};
|
||||||
|
const b: TGChannel = status.b ?? {};
|
||||||
|
|
||||||
|
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||||
|
return (
|
||||||
|
<Card icon={Gauge} title={title} accent="#f59e0b">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.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')}>
|
||||||
|
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||||
|
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{status.message && (
|
||||||
|
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<>
|
||||||
|
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||||
|
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
|
||||||
|
3-wide because it shows three meters; copying it here left the tuner's
|
||||||
|
two meters in the first two of three columns, with a third of the card
|
||||||
|
empty to the right of SWR. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||||
|
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Gauge, X, Power, Radio } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type TGChannel = {
|
||||||
|
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
||||||
|
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
||||||
|
};
|
||||||
|
export type TGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||||
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||||
|
antenna?: number; three_way?: boolean; message?: string;
|
||||||
|
a?: TGChannel; b?: TGChannel;
|
||||||
|
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
||||||
|
// red above (the usual "safe / caution / high" ATU thresholds).
|
||||||
|
function swrColour(vswr?: number): string {
|
||||||
|
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
||||||
|
if (vswr <= 1.5) return 'text-success';
|
||||||
|
if (vswr <= 2.0) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
||||||
|
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
||||||
|
// and clicking it makes that channel active.
|
||||||
|
function ChanRow({ letter, ch, active, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
||||||
|
t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ptt = !!ch.ptt;
|
||||||
|
const cls = ptt
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
||||||
|
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
||||||
|
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (
|
||||||
|
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
||||||
|
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
||||||
|
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
||||||
|
// panel and Station Control.
|
||||||
|
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
||||||
|
status: TGStatus;
|
||||||
|
onTune: () => void;
|
||||||
|
onBypass: (on: boolean) => void;
|
||||||
|
onOperate: (on: boolean) => void;
|
||||||
|
onActivate: (ch: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||||
|
// between syllables as often as on a peak, so the plain figure showed a few
|
||||||
|
// hundred watts on a kilowatt transmission.
|
||||||
|
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Gauge 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">Tuner Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<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('tgp.online') : t('tgp.offline')}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
||||||
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
||||||
|
{/* SWR + forward power readouts */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
||||||
|
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
||||||
|
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||||
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||||
|
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel A / B — click to activate */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
||||||
|
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.message && (
|
||||||
|
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tune */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTune}
|
||||||
|
disabled={status.tuning}
|
||||||
|
title={t('tgp.tuneHint')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
||||||
|
status.tuning
|
||||||
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
||||||
|
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
<Radio className="size-4" />
|
||||||
|
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bypass + Operate toggles */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBypass(!status.bypass)}
|
||||||
|
title={t('tgp.bypassHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.bypass
|
||||||
|
? '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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOperate(!status.operate)}
|
||||||
|
title={t('tgp.operateHint')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.operate
|
||||||
|
? '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-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
@@ -24,7 +24,13 @@ type UDPConfig = {
|
|||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
port: number;
|
||||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'custom';
|
||||||
|
// Custom rows only.
|
||||||
|
trigger?: string;
|
||||||
|
template?: string;
|
||||||
|
transport?: string;
|
||||||
|
url?: string;
|
||||||
|
line_end?: string;
|
||||||
multicast: boolean;
|
multicast: boolean;
|
||||||
multicast_group: string;
|
multicast_group: string;
|
||||||
destination_ip: string;
|
destination_ip: string;
|
||||||
@@ -77,6 +83,28 @@ const SERVICE_TYPES: Array<{
|
|||||||
hint: 'udpp.svcDbHint',
|
hint: 'udpp.svcDbHint',
|
||||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Announces the QSO on the WSJT-X UDP interface instead of as plain text.
|
||||||
|
// Named for the protocol, not for one program: Logger32 is what it was
|
||||||
|
// built against, but anything listening on that interface takes it, and a
|
||||||
|
// label saying "Logger32" would have every other operator scroll past.
|
||||||
|
// 2250 is Logger32's first additional socket — a starting point, not a rule.
|
||||||
|
id: 'wsjt_log',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcWsjtLogLabel',
|
||||||
|
hint: 'udpp.svcWsjtLogHint',
|
||||||
|
defaults: { port: 2250, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The general case: the operator picks the moment and writes the message.
|
||||||
|
// Mainly for antenna switches, which are driven by a URL and want the band
|
||||||
|
// change — but nothing about it is specific to them.
|
||||||
|
id: 'custom',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcCustomLabel',
|
||||||
|
hint: 'udpp.svcCustomHint',
|
||||||
|
defaults: { port: 12000, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pstrotator_freq',
|
id: 'pstrotator_freq',
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
@@ -93,6 +121,27 @@ const SERVICE_TYPES: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The fields each trigger carries, and NOTHING else.
|
||||||
|
//
|
||||||
|
// This list is the point of the whole feature. A rotation has no frequency, a
|
||||||
|
// lookup has no report — and a placeholder the trigger cannot fill renders as
|
||||||
|
// nothing at all. Without seeing what is available, an operator writes {freq}
|
||||||
|
// on a band change, gets an empty string, and has no way to learn why. So the
|
||||||
|
// panel prints them under the box, for the trigger actually selected.
|
||||||
|
const TRIGGER_FIELDS: Record<string, string[]> = {
|
||||||
|
qso_logged: ['call', 'band', 'band_m', 'mode', 'freq_hz', 'freq_mhz', 'grid', 'name', 'country', 'dxcc', 'rst_s', 'rst_r', 'comment', 'date', 'time'],
|
||||||
|
rotator_goto: ['az', 'el', 'path'],
|
||||||
|
lookup_done: ['call', 'name', 'grid', 'country', 'qth', 'state', 'dxcc'],
|
||||||
|
band_change: ['band', 'band_m', 'freq_hz', 'freq_mhz', 'mode'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGERS = [
|
||||||
|
{ id: 'band_change', label: 'udpp.trgBand' },
|
||||||
|
{ id: 'qso_logged', label: 'udpp.trgQso' },
|
||||||
|
{ id: 'rotator_goto', label: 'udpp.trgRotator' },
|
||||||
|
{ id: 'lookup_done', label: 'udpp.trgLookup' },
|
||||||
|
];
|
||||||
|
|
||||||
type Props = { onError: (msg: string) => void };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
@@ -124,6 +173,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
destination_ip: preset.defaults.destination_ip ?? '',
|
destination_ip: preset.defaults.destination_ip ?? '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sort_order: items.filter((i) => i.direction === direction).length,
|
sort_order: items.filter((i) => i.direction === direction).length,
|
||||||
|
trigger: '', template: '', transport: 'udp', url: '', line_end: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +371,6 @@ function EditDialog({
|
|||||||
<Label>{t('udpp.name')}</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -377,7 +426,7 @@ function EditDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.direction === 'outbound' && (
|
{draft.direction === 'outbound' && draft.service_type !== 'custom' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('udpp.destinationIp')}</Label>
|
<Label>{t('udpp.destinationIp')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -389,6 +438,10 @@ function EditDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{draft.service_type === 'custom' && (
|
||||||
|
<CustomFields draft={draft} setDraft={setDraft} />
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
@@ -414,3 +467,108 @@ function EditDialog({
|
|||||||
|
|
||||||
// silence unused-import for cn — kept for future styling tweaks
|
// silence unused-import for cn — kept for future styling tweaks
|
||||||
void cn;
|
void cn;
|
||||||
|
|
||||||
|
// CustomFields is the editor for a custom outbound row: what fires it, how it
|
||||||
|
// leaves, and what it says.
|
||||||
|
function CustomFields({ draft, setDraft }: {
|
||||||
|
draft: UDPConfig;
|
||||||
|
setDraft: React.Dispatch<React.SetStateAction<UDPConfig>>;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
|
||||||
|
const isURL = draft.transport === 'url';
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border border-border/60 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.trigger')}</Label>
|
||||||
|
<Select value={draft.trigger || 'band_change'} onValueChange={(v) => setDraft((d) => ({ ...d, trigger: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TRIGGERS.map((x) => <SelectItem key={x.id} value={x.id}>{t(x.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.transport')}</Label>
|
||||||
|
<Select value={draft.transport || 'udp'} onValueChange={(v) => setDraft((d) => ({ ...d, transport: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="udp">{t('udpp.transportUdp')}</SelectItem>
|
||||||
|
<SelectItem value="url">{t('udpp.transportUrl')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The one place these two features overlap. A relay board is a STATE per
|
||||||
|
band, not an event: Station Control tracks it, reads the boards at
|
||||||
|
startup and does not re-switch while the VFO moves inside a band. A
|
||||||
|
URL fired on a band change has none of that, and building the mapping
|
||||||
|
by hand here is work that is already done there. */}
|
||||||
|
{draft.trigger === 'band_change' && isURL && (
|
||||||
|
<div className="rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground leading-relaxed">
|
||||||
|
{t('udpp.relayInstead')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isURL ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.url')}</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono text-xs"
|
||||||
|
placeholder="http://192.168.1.50/antenna?band={band}"
|
||||||
|
value={draft.url ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('udpp.urlHint')}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.destinationIp')}</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
value={draft.destination_ip}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.lineEnd')}</Label>
|
||||||
|
<Select value={draft.line_end || 'none'} onValueChange={(v) => setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">{t('udpp.lineEndNone')}</SelectItem>
|
||||||
|
<SelectItem value="lf">LF</SelectItem>
|
||||||
|
<SelectItem value="crlf">CRLF</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.template')}</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full min-h-[64px] rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
|
||||||
|
placeholder="<AZIMUT>{az}</AZIMUT>"
|
||||||
|
value={draft.template ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, template: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What this trigger can actually fill in. */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('udpp.fieldsAvailable')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<code key={f} className="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{`{${f}}`}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('udpp.fieldsHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Upload, FolderOpen, Loader2, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
|
||||||
|
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type Cfg = {
|
||||||
|
enabled: boolean; format: string; folder: string; file_name: string; title: string;
|
||||||
|
count: number; interval_min: number; columns: string[];
|
||||||
|
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||||||
|
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||||||
|
};
|
||||||
|
type Col = { key: string; header: string; group: string };
|
||||||
|
|
||||||
|
export function WebPublishPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||||||
|
const [cols, setCols] = useState<Col[]>([]);
|
||||||
|
// The catalogue is 123 fields, so the picker is a dropdown with a search box
|
||||||
|
// rather than anything laid out on the page. Chosen columns stay visible above
|
||||||
|
// it, in publication order: after picking eight out of a hundred the question
|
||||||
|
// stops being 'what exists' and becomes 'what did I pick, and how will it print'.
|
||||||
|
const [colSearch, setColSearch] = useState('');
|
||||||
|
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// Raw text for the two numeric boxes: bound straight to the number they could
|
||||||
|
// not be cleared, the same trap as the Recent-QSOs Max field.
|
||||||
|
const [countText, setCountText] = useState('');
|
||||||
|
const [everyText, setEveryText] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
|
||||||
|
setCfg(c as any);
|
||||||
|
setCols((k ?? []) as Col[]);
|
||||||
|
setCountText(String((c as any).count ?? 100));
|
||||||
|
setEveryText(String((c as any).interval_min ?? 0));
|
||||||
|
const s: any = st;
|
||||||
|
if (s?.last_err) setErr(s.last_err);
|
||||||
|
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!cfg) return <p className="text-xs text-muted-foreground">…</p>;
|
||||||
|
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
|
||||||
|
|
||||||
|
// Every control saves immediately: this panel has no Save button of its own,
|
||||||
|
// matching the other "saved instantly" panels.
|
||||||
|
const save = async (next: Cfg) => {
|
||||||
|
setCfg(next);
|
||||||
|
try { await SaveWebPublishConfig(next as any); setErr(''); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
};
|
||||||
|
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
|
||||||
|
|
||||||
|
const toggleCol = (key: string) => {
|
||||||
|
const has = cfg.columns?.includes(key);
|
||||||
|
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (what: 'test' | 'publish') => {
|
||||||
|
setBusy(what); setMsg(''); setErr('');
|
||||||
|
try {
|
||||||
|
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
|
||||||
|
setMsg(String(r));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||||||
|
{t('wpub.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (<>
|
||||||
|
{/* ── The file ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">{t('wpub.format')}</Label>
|
||||||
|
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||||||
|
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||||||
|
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||||||
|
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||||||
|
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||||||
|
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||||||
|
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.title')}</Label>
|
||||||
|
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||||||
|
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.count')}</Label>
|
||||||
|
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||||||
|
onChange={(e) => setCountText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = Math.floor(Number(countText));
|
||||||
|
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.every')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||||||
|
onChange={(e) => setEveryText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = Math.floor(Number(everyText));
|
||||||
|
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
|
||||||
|
}} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Columns ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{t('wpub.columnsCount', { n: cfg.columns?.length ?? 0, total: cols.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CHOSEN, in publication order — this is the list that answers "what
|
||||||
|
will the page look like", which a sectioned catalogue cannot. */}
|
||||||
|
{(cfg.columns?.length ?? 0) > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cfg.columns.map((k) => {
|
||||||
|
const c = cols.find((x) => x.key === k);
|
||||||
|
return (
|
||||||
|
<button key={k} type="button" onClick={() => toggleCol(k)}
|
||||||
|
title={t('wpub.removeColumn')}
|
||||||
|
className="px-2 py-0.5 rounded-full border border-primary bg-primary text-primary-foreground text-[11px] font-medium">
|
||||||
|
{c?.header ?? k} ×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* One dropdown, alphabetical, filtered as you type. The catalogue is
|
||||||
|
123 fields: laid out on the page it buries every other setting, and
|
||||||
|
sorting by anything but the alphabet means hunting. The menu stays
|
||||||
|
open while ticking — picking eight columns should be one visit. */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-xs font-normal">
|
||||||
|
{t('wpub.columnsPick')}
|
||||||
|
<ChevronsUpDown className="size-3.5 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{/* The search box is OUTSIDE the scrolling area, not sticky inside it.
|
||||||
|
Sticky kept it in place but the rows scrolled over it: a menu item
|
||||||
|
carries its own background and its own stacking, so it wins over a
|
||||||
|
sticky sibling however high its z-index. A header that never
|
||||||
|
scrolls has nothing to lose the fight with. */}
|
||||||
|
<DropdownMenuContent align="start" className="w-72 p-0 flex flex-col max-h-80">
|
||||||
|
<div className="p-1.5 border-b border-border/60 bg-popover shrink-0">
|
||||||
|
<Input className="h-7 text-xs" placeholder={t('wpub.columnsSearch')}
|
||||||
|
value={colSearch}
|
||||||
|
onChange={(e) => setColSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-1">
|
||||||
|
{[...cols]
|
||||||
|
.sort((a, b) => a.header.localeCompare(b.header))
|
||||||
|
.filter((c) => {
|
||||||
|
const q = colSearch.trim().toLowerCase();
|
||||||
|
return !q || c.header.toLowerCase().includes(q) || c.key.toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.map((c) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={c.key}
|
||||||
|
checked={cfg.columns?.includes(c.key) ?? false}
|
||||||
|
onCheckedChange={() => toggleCol(c.key)}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
<span className="ml-auto pl-2 text-[10px] text-muted-foreground font-mono">{c.group}</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Upload ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||||||
|
{t('wpub.ftpEnable')}
|
||||||
|
</label>
|
||||||
|
{cfg.ftp_enabled && (
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||||||
|
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||||||
|
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||||||
|
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||||||
|
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||||||
|
</div>
|
||||||
|
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||||||
|
<Input className="h-8" value={cfg.ftp_user}
|
||||||
|
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||||||
|
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||||||
|
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||||||
|
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||||||
|
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||||||
|
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||||||
|
<span />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||||||
|
{t('wpub.ftpTls')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||||||
|
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||||||
|
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||||||
|
{t('wpub.publishNow')}
|
||||||
|
</Button>
|
||||||
|
{cfg.ftp_enabled && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||||||
|
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
{t('wpub.testFtp')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||||||
|
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,14 +101,18 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
{source === 'flex'
|
||||||
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
|
: source === 'yaesu' || source === 'kenwood'
|
||||||
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +187,7 @@ export function WinkeyerPanel({
|
|||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
{t('wkp.cwText')}
|
{t('wkp.cwText')}
|
||||||
{source === 'winkeyer' && (
|
{(source === 'winkeyer' || source === 'flex') && (
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
title={t('wkp.sendOnTypeHint')}>
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
@@ -228,9 +232,14 @@ export function WinkeyerPanel({
|
|||||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||||
|
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||||
|
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{macros.map((m, i) => (
|
{macros
|
||||||
|
.map((m, i) => ({ m, i }))
|
||||||
|
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||||
|
.map(({ m, i }) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
|
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -25,6 +26,8 @@ const hamlogTheme = hamlogGridTheme;
|
|||||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||||
|
myGrid?: string;
|
||||||
wb: WorkedBeforeView | null;
|
wb: WorkedBeforeView | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentCall: string;
|
currentCall: string;
|
||||||
@@ -35,6 +38,10 @@ type Props = {
|
|||||||
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;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award (cell = the reference this QSO counts for).
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
@@ -50,14 +57,16 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// Localized column catalog (shared with the Recent QSOs grid).
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -107,15 +116,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// 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, so it
|
||||||
|
// used to miss the cache every time and then save under the scoped key.
|
||||||
|
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 events fired by a column rebuild
|
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||||
@@ -131,7 +143,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||||
|
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
@@ -247,6 +261,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||||||
|
mean "the whole logbook under the active column filters", but this grid
|
||||||
|
is a per-callsign view rather than a filter over the log, so the entry
|
||||||
|
would quietly export everything — not what it would appear to do here.
|
||||||
|
The selection-based exports and bulk edit apply unchanged. */}
|
||||||
<QSOContextMenu
|
<QSOContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
@@ -256,6 +275,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetYaesuState, RefreshYaesuPanel,
|
||||||
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||||
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||||
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||||
|
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
|
||||||
|
type YaesuState = {
|
||||||
|
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
||||||
|
transmitting: boolean; split: boolean;
|
||||||
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
|
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||||
|
agc?: string; preamp: number; att: number;
|
||||||
|
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||||
|
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: YaesuState = {
|
||||||
|
available: false, transmitting: false, split: false,
|
||||||
|
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||||
|
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||||
|
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||||
|
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||||
|
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||||
|
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
|
||||||
|
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||||
|
// the operator is the one who knows which they want, so each shows its sideband
|
||||||
|
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||||
|
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||||
|
// the band plan dictates, and AM/FM have none.
|
||||||
|
//
|
||||||
|
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||||
|
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||||
|
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||||
|
const MODES: ModeBtn[] = [
|
||||||
|
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||||
|
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||||
|
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||||
|
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||||
|
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||||
|
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||||
|
switch ((raw || '').toUpperCase()) {
|
||||||
|
case 'USB': return { id: 'SSB', side: 'U' };
|
||||||
|
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||||
|
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||||
|
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||||
|
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||||
|
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||||
|
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||||
|
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||||
|
case 'AM': return { id: 'AM', side: 'U' };
|
||||||
|
case 'FM': return { id: 'FM', side: 'U' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||||
|
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||||
|
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||||
|
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||||
|
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||||
|
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||||
|
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
|
|
||||||
|
function fmtVFO(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '––.–––.––';
|
||||||
|
const mhz = Math.floor(hz / 1_000_000);
|
||||||
|
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||||
|
const h2 = Math.floor((hz % 1000) / 10);
|
||||||
|
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandOfHz(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '';
|
||||||
|
const mhz = hz / 1_000_000;
|
||||||
|
const bands: [string, number, number][] = [
|
||||||
|
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||||
|
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||||
|
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||||
|
];
|
||||||
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||||
|
//
|
||||||
|
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||||
|
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||||
|
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||||
|
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||||
|
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||||
|
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||||
|
|
||||||
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
|
if (v >= S9_PCT) {
|
||||||
|
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||||
|
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||||
|
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||||
|
// change always lands exactly where the numbers say it should — if the S9 point
|
||||||
|
// is ever corrected, the colours follow on their own.
|
||||||
|
const RED_OVER_DB = 20;
|
||||||
|
function sSegColor(frac: number): string {
|
||||||
|
const pct = frac * 100;
|
||||||
|
if (pct < S9_PCT) return '#16a34a';
|
||||||
|
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||||
|
return '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||||
|
// native non-passive listener, and read live values through refs so the
|
||||||
|
// handler never closes over a stale value.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const minRef = useRef(min); minRef.current = min;
|
||||||
|
const maxRef = useRef(max); maxRef.current = max;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (disRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||||
|
if (nv !== valRef.current) cbRef.current(nv);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range" min={min} max={max} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||||
|
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||||
|
// The filled side was invisible against the dark theme: --muted is barely
|
||||||
|
// lighter than the card it sits on, so the whole track read as one bar.
|
||||||
|
// An explicit translucent track keeps both halves distinct in either theme.
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||||
|
borderColor: accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, options, onChange }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} title={title}
|
||||||
|
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||||
|
onReportRST?: (rst: string) => void;
|
||||||
|
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||||
|
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||||
|
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||||
|
// slider looked broken because it was driving the wrong keyer.
|
||||||
|
onKeySpeed?: (wpm: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||||
|
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||||
|
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||||
|
// main display showed the operator the frequency they transmit on and an
|
||||||
|
// offset of 0 kHz against itself.
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [txHz, setTxHz] = useState(0);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// Optimistic local values for the sliders. Without them a drag fights the
|
||||||
|
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||||
|
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||||
|
const localAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetYaesuState()) as YaesuState;
|
||||||
|
const c = await GetCATState();
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(s);
|
||||||
|
const cs = c as any;
|
||||||
|
const tx = cs?.freq_hz ?? 0;
|
||||||
|
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||||
|
setFreqHz(rx);
|
||||||
|
setTxHz(tx);
|
||||||
|
// Drop the optimistic overlay once the rig has had time to answer with
|
||||||
|
// the new value — 1.2 s covers the slow-beat settings read.
|
||||||
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const view = { ...st, ...local };
|
||||||
|
|
||||||
|
// Every setter follows the same shape: show the value at once, remember when,
|
||||||
|
// and let the poll take over. A rejected command surfaces as an error rather
|
||||||
|
// than as a control that silently springs back.
|
||||||
|
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||||
|
setLocal((l) => ({ ...l, [key]: value }));
|
||||||
|
localAtRef.current = Date.now();
|
||||||
|
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const band = bandOfHz(freqHz);
|
||||||
|
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||||
|
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||||
|
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||||
|
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||||
|
it without mx-auto left the console pinned to the left edge with a
|
||||||
|
window of empty space beside it. */}
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* VFO + status */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||||
|
{view.split && txHz > 0 && (
|
||||||
|
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||||
|
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||||
|
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{st.transmitting && (
|
||||||
|
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||||
|
)}
|
||||||
|
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||||
|
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||||
|
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||||
|
guessed, and each turns split on in the same action. */}
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||||
|
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.tuneHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||||
|
|
||||||
|
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||||
|
consoles read alike instead of each having its own instrument style. */}
|
||||||
|
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={sParts(view.s_meter).label}
|
||||||
|
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||||
|
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||||
|
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||||
|
the setting says what was asked for, the meter says what left. */}
|
||||||
|
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||||
|
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||||
|
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||||
|
not something an operator can act on. The bar keeps the travel. */}
|
||||||
|
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||||
|
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bands + modes */}
|
||||||
|
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{BANDS.map((b) => (
|
||||||
|
<button key={b} type="button"
|
||||||
|
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{b.replace('m', '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const act = activeMode(view.raw_mode);
|
||||||
|
const on = act?.id === m.id;
|
||||||
|
// The sideband shown is the rig's when this mode is active, else the
|
||||||
|
// one the band plan implies — so a button says what pressing it will
|
||||||
|
// actually do rather than a stale letter.
|
||||||
|
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||||
|
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||||
|
return (
|
||||||
|
<button key={m.id} type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Already on this mode → the click means "the other sideband".
|
||||||
|
const target = m.id === 'SSB'
|
||||||
|
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||||
|
: m.rig(on && m.sideband ? flip : side);
|
||||||
|
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{m.sideband ? m.label + '-' + side : m.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive */}
|
||||||
|
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="SQL">
|
||||||
|
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="FRONT">
|
||||||
|
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="ATT">
|
||||||
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Noise + filter */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||||
|
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||||
|
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||||
|
</div>
|
||||||
|
<Row label="DNR">
|
||||||
|
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||||
|
the radio's own display counts 1-15, and matching it is what makes
|
||||||
|
the panel readable next to the front panel. */}
|
||||||
|
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||||
|
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transmit */}
|
||||||
|
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||||
|
<Row label="PWR">
|
||||||
|
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||||
|
percentage would be a second unit to reconcile every time. */}
|
||||||
|
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||||
|
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||||
|
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||||
|
</Row>
|
||||||
|
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||||
|
— so they are hidden rather than shown as dead controls. */}
|
||||||
|
{!isCW && (
|
||||||
|
<>
|
||||||
|
<Row label="MIC">
|
||||||
|
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="VOX">
|
||||||
|
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Row label="">
|
||||||
|
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
{t('yaesu.refresh')}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||||
|
{isCW && (
|
||||||
|
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||||
|
<Row label="SPEED">
|
||||||
|
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||||
|
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||||
|
</Row>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||||
|
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||||
|
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||||
|
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||||
|
plain button rather than a chip that would look latched. */}
|
||||||
|
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.zinHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
ZIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
|||||||
|
|
||||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||||
|
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||||
const avail = box.w - 56;
|
const avail = box.w - 56;
|
||||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||||
let cursor = 28;
|
let cursor = 28;
|
||||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{box.title}
|
{box.title}
|
||||||
</text>
|
</text>
|
||||||
{cols.map(({ f, x }) => (
|
{cols.map(({ f, x }) => (
|
||||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{values[f] ?? ''}
|
{values[f] ?? ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{box.footer && (
|
{box.footer && (
|
||||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||||
{box.footer}
|
{box.footer}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
onChange={(w) => onPatchBox({ w })} />
|
onChange={(w) => onPatchBox({ w })} />
|
||||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||||
onChange={(h) => onPatchBox({ h })} />
|
onChange={(h) => onPatchBox({ h })} />
|
||||||
|
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||||
|
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||||
|
onChange={(radius) => onPatchBox({ radius })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||||
|
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
|
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
|
||||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
} from '../../../wailsjs/go/main/App';
|
} from '../../../wailsjs/go/main/App';
|
||||||
@@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||||
|
const [foreign, setForeign] = useState<any[]>([]);
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
@@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
if (url) p[t.id] = url;
|
if (url) p[t.id] = url;
|
||||||
}));
|
}));
|
||||||
setPreviews(p);
|
setPreviews(p);
|
||||||
|
// The designs this profile cannot see, offered for copying.
|
||||||
|
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||||
|
{/* Designs owned by another profile. A second profile is usually
|
||||||
|
the same operator with a different rig or locator — same
|
||||||
|
callsign, same cards — and redrawing one because the antenna
|
||||||
|
changed is work nobody should have to do. */}
|
||||||
|
{!!foreign.length && (
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={async (e) => {
|
||||||
|
const id = parseInt(e.target.value, 10);
|
||||||
|
if (!id) return;
|
||||||
|
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
|
||||||
|
catch (err) { setError(String(err)); }
|
||||||
|
}}
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
|
||||||
|
<option value="">Copy from another profile…</option>
|
||||||
|
{foreign.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.profile_name} — {f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{saved.map((t) => (
|
{saved.map((t) => (
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
|||||||
h: number;
|
h: number;
|
||||||
bg: string;
|
bg: string;
|
||||||
bg_opacity: number;
|
bg_opacity: number;
|
||||||
|
fg?: string; // text colour (default dark); set it when using a dark background
|
||||||
radius: number;
|
radius: number;
|
||||||
title: string;
|
title: string;
|
||||||
fields: string[];
|
fields: string[];
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
|
// that's actually in the list, so a half-typed or invalid report never
|
||||||
|
// becomes the committed value — blur then reverts the leftover text.
|
||||||
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||||
|
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||||
|
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||||
|
// a container — what it holds carries its own focus styling.
|
||||||
|
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||||
|
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||||
|
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||||
|
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||||
|
function removeTokens(existing: string | undefined, refs: string): string {
|
||||||
|
let out = existing ?? '';
|
||||||
|
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||||
|
out = out.replace(re, ' ');
|
||||||
|
}
|
||||||
|
return out.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||||
// The award engine honours these regardless of how the award matches, so a
|
// The award engine honours these regardless of how the award matches, so a
|
||||||
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
|||||||
// data lives in its conventional place and exports correctly. Awards that match
|
// data lives in its conventional place and exports correctly. Awards that match
|
||||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||||
// on the override — we don't pollute the text field with a code.
|
// on the override — we don't pollute the text field with a code.
|
||||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||||
const byCode = parseAwardRefs(awardRefs);
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
// References the operator REMOVED since the editor opened: for in-field awards
|
||||||
|
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||||
|
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||||
|
if (prevAwardRefs != null) {
|
||||||
|
const prev = parseAwardRefs(prevAwardRefs);
|
||||||
|
for (const [code, prevRef] of Object.entries(prev)) {
|
||||||
|
const field = fieldOf[code] || code.toLowerCase();
|
||||||
|
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||||
|
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||||
|
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||||
|
if (removed.length === 0) continue;
|
||||||
|
const rm = removed.join(',');
|
||||||
|
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||||
|
else payload.notes = removeTokens(payload.notes, rm);
|
||||||
|
}
|
||||||
|
}
|
||||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
const overrides: string[] = [];
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
for (const [code, ref] of Object.entries(byCode)) {
|
||||||
@@ -125,3 +154,41 @@ export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field:
|
|||||||
}
|
}
|
||||||
return out.join(';');
|
return out.join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COMPUTED_AWARD_FIELDS mirrors isComputedAwardField in app.go: awards whose
|
||||||
|
// reference is derived from the callsign and cty.dat, never assigned by hand.
|
||||||
|
//
|
||||||
|
// State and county are deliberately absent, there as here — an operator sets
|
||||||
|
// those themselves and they drive real predefined-list awards (WAS, WAJA, JCC).
|
||||||
|
const COMPUTED_AWARD_FIELDS = new Set([
|
||||||
|
'dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// spotRefList turns a QSO's code→ref map into the strings a DX-cluster comment
|
||||||
|
// wants: ["SOTA HA/KM-018"].
|
||||||
|
//
|
||||||
|
// COMPUTED awards are dropped. A QSO's materialised references hold both kinds,
|
||||||
|
// and the derived ones — DXCC, WAC, WAZ, WPX — are exactly what every reader of
|
||||||
|
// the spot works out from the callsign anyway. Announcing "WAC EU WAZ 15 WPX HA5"
|
||||||
|
// spends the whole 30-character comment saying nothing, and buries the one
|
||||||
|
// reference worth chasing. Same rule as the QSO editor, which is why these are
|
||||||
|
// the references it lists on the LEFT and not in its read-only panel.
|
||||||
|
//
|
||||||
|
// fieldOf is award code → scanned field (from GetAwardDefs). A code missing from
|
||||||
|
// it is dropped rather than guessed: showing a reference on the air matters more
|
||||||
|
// than showing all of them.
|
||||||
|
//
|
||||||
|
// A code carrying several references (a two-fer park) yields one entry each, so
|
||||||
|
// a spot can take the one that fits instead of emitting a truncated pair.
|
||||||
|
// Shortest first: when they cannot all fit, more references beat fewer.
|
||||||
|
export function spotRefList(byCode: Record<string, string>, fieldOf: Record<string, string>): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [code, refs] of Object.entries(byCode ?? {})) {
|
||||||
|
const field = fieldOf?.[code.toUpperCase()];
|
||||||
|
if (!field || COMPUTED_AWARD_FIELDS.has(field)) continue;
|
||||||
|
for (const ref of String(refs).split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
out.push(`${code.toUpperCase()} ${ref.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||||
|
//
|
||||||
|
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||||
|
// upload. This is the reading layer only, because an operator reading
|
||||||
|
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||||
|
// and that is a display problem, not a data one.
|
||||||
|
//
|
||||||
|
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||||
|
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||||
|
// preference would become unreadable the day the preference changed.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||||
|
|
||||||
|
const KEY = 'opslog.dateFormat';
|
||||||
|
|
||||||
|
function read(): DateFormat {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: DateFormat = read();
|
||||||
|
|
||||||
|
// A minimal store so every grid and panel re-renders the moment the format
|
||||||
|
// changes, rather than showing the old one until the next restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getDateFormat(): DateFormat {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDateFormat(f: DateFormat): void {
|
||||||
|
current = f;
|
||||||
|
writeUiPref(KEY, f);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDateFormat(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||||
|
// have been synced from the database at startup, which happens AFTER this
|
||||||
|
// module's first read.
|
||||||
|
export function reloadDateFormat(): void {
|
||||||
|
const v = read();
|
||||||
|
if (v !== current) {
|
||||||
|
current = v;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||||
|
// in the French one would be conventional but it also invites the reader to
|
||||||
|
// take it for a US date, which is exactly the confusion being fixed.
|
||||||
|
function order(y: number, m: number, d: number): string {
|
||||||
|
switch (current) {
|
||||||
|
case 'fr':
|
||||||
|
return `${pad(d)}-${pad(m)}-${y}`;
|
||||||
|
case 'us':
|
||||||
|
return `${pad(m)}-${pad(d)}-${y}`;
|
||||||
|
}
|
||||||
|
return `${y}-${pad(m)}-${pad(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||||
|
//
|
||||||
|
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||||
|
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||||
|
// twice a year.
|
||||||
|
export function formatDateTimeUTC(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(String(s));
|
||||||
|
if (isNaN(d.getTime())) return String(s);
|
||||||
|
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||||
|
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||||
|
export function formatDateOnly(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = String(s).trim();
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
const d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
|
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Grey line — the day/night terminator, and the twilight band around it where
|
||||||
|
// HF propagation briefly does things it does at no other time.
|
||||||
|
//
|
||||||
|
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||||
|
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||||
|
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||||
|
// this has to work in a field shack with no internet.
|
||||||
|
//
|
||||||
|
// Everything here is pure so it can be tested against known positions; the
|
||||||
|
// drawing lives in the map component.
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// julianDay for a date, including the fraction of the day.
|
||||||
|
export function julianDay(d: Date): number {
|
||||||
|
return d.getTime() / 86400000 + 2440587.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||||
|
//
|
||||||
|
// Returned together because they are computed from the same intermediates and
|
||||||
|
// every caller needs both — splitting them into two exported functions would
|
||||||
|
// double the work at each call for the sake of a tidier signature.
|
||||||
|
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||||
|
const jd = julianDay(date);
|
||||||
|
const n = jd - 2451545.0; // days from J2000.0
|
||||||
|
|
||||||
|
// Mean longitude and mean anomaly of the Sun.
|
||||||
|
const L = (280.460 + 0.9856474 * n) % 360;
|
||||||
|
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||||
|
|
||||||
|
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||||
|
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||||
|
|
||||||
|
// Obliquity of the ecliptic, slowly decreasing.
|
||||||
|
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||||
|
|
||||||
|
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||||
|
|
||||||
|
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||||
|
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||||
|
// terminator about the prime meridian. Six months of a plausible-looking
|
||||||
|
// wrong answer is exactly the kind of bug that survives review.
|
||||||
|
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||||
|
if (ra < 0) ra += 360;
|
||||||
|
|
||||||
|
// Greenwich mean sidereal time, in degrees.
|
||||||
|
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||||
|
|
||||||
|
let gha = gmst - ra;
|
||||||
|
gha = ((gha % 360) + 360) % 360;
|
||||||
|
return { dec, gha };
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||||
|
// sits at `altitude` degrees above (or below) the horizon.
|
||||||
|
//
|
||||||
|
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||||
|
// of the grey line as operators use the term.
|
||||||
|
//
|
||||||
|
// Returns null where no such latitude exists at that longitude — inside the
|
||||||
|
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||||
|
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const h = altitude * DEG;
|
||||||
|
|
||||||
|
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||||
|
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||||
|
//
|
||||||
|
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||||
|
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||||
|
// local in December came out as daylight because the branch chosen put the
|
||||||
|
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||||
|
// entirely plausible and is exactly wrong.
|
||||||
|
//
|
||||||
|
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||||
|
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||||
|
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||||
|
// which is the physical requirement — the twilight curve is a small offset
|
||||||
|
// from the terminator, never on the other side of the planet.
|
||||||
|
const A = Math.sin(d);
|
||||||
|
const B = Math.cos(d) * Math.cos(ha);
|
||||||
|
const R = Math.hypot(A, B);
|
||||||
|
if (R === 0) return null;
|
||||||
|
const s = Math.sin(h) / R;
|
||||||
|
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||||
|
|
||||||
|
// The classical terminator. Division by zero is deliberate and correct here:
|
||||||
|
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||||
|
// exactly what atan(±Infinity) = ±90° expresses.
|
||||||
|
const t = Math.tan(d);
|
||||||
|
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||||
|
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||||
|
|
||||||
|
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||||
|
const fold = (x: number) => {
|
||||||
|
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||||
|
if (v > 90) v = 180 - v;
|
||||||
|
if (v < -90) v = -180 - v;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||||
|
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||||
|
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||||
|
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||||
|
//
|
||||||
|
// The ring runs along the terminator from west to east and closes along
|
||||||
|
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||||
|
// getting it wrong shades the lit half of the planet.
|
||||||
|
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||||
|
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||||
|
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||||
|
// ring stays closed rather than tearing open across the map.
|
||||||
|
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||||
|
const pole = dec > 0 ? -90 : 90;
|
||||||
|
ring.push([pole, 180]);
|
||||||
|
ring.push([pole, -180]);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
@@ -6,11 +6,52 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||||
|
// the active profile id). The localStorage cache, however, is one namespace for
|
||||||
|
// the whole WebView, so without scoping it too a profile switch would keep
|
||||||
|
// serving the previous profile's cached layout and the correct per-profile DB
|
||||||
|
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||||
|
// once the active profile is known and updated on every profile switch.
|
||||||
|
let lsScope = '';
|
||||||
|
|
||||||
|
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||||
|
// the grids read their state (at startup) and again whenever the active profile
|
||||||
|
// changes so each profile keeps its own column layout / widths.
|
||||||
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active profile is only known after an async call, but the grids mount and
|
||||||
|
// read their state on the first paint. A grid that read before the scope was set
|
||||||
|
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||||
|
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||||
|
// wait for this instead of racing it.
|
||||||
|
//
|
||||||
|
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||||
|
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||||
|
// columns restored.
|
||||||
|
let markReady: () => void = () => {};
|
||||||
|
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||||
|
markReady = resolve;
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function whenGridPrefsReady(): Promise<void> {
|
||||||
|
return gridPrefsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
|
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||||
|
function lsKey(key: string): string {
|
||||||
|
return lsScope + key;
|
||||||
|
}
|
||||||
|
|
||||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -19,25 +60,70 @@ export function loadLocal(key: string): any[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
//
|
||||||
|
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||||
|
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||||
|
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||||
|
// first column event then wrote those defaults over the good saved copy. So
|
||||||
|
// retry for a few seconds instead, and only give up on a real absence.
|
||||||
|
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
try {
|
try {
|
||||||
const v = await GetUIPref(key);
|
const v = await GetUIPref(key);
|
||||||
const parsed = v ? JSON.parse(v) : null;
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
} catch {
|
} catch {
|
||||||
|
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||||
|
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
|
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||||
|
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||||
|
// cache write stays synchronous so nothing is lost if the window closes.
|
||||||
|
const pendingDB = new Map<string, string>();
|
||||||
|
const dbTimers = new Map<string, number>();
|
||||||
|
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
pendingDB.set(key, json);
|
||||||
|
const prev = dbTimers.get(key);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOne(key: string) {
|
||||||
|
const json = pendingDB.get(key);
|
||||||
|
dbTimers.delete(key);
|
||||||
|
if (json == null) return;
|
||||||
|
pendingDB.delete(key);
|
||||||
|
SetUIPref(key, json).catch(() => {
|
||||||
|
// The store may not be scoped yet at startup. Keep the value and try once
|
||||||
|
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||||
|
// portable copy at all.
|
||||||
|
pendingDB.set(key, json);
|
||||||
|
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||||
|
// about to close so a resize made in the last moments still reaches the DB.
|
||||||
|
export function flushGridPrefs() {
|
||||||
|
for (const key of Array.from(pendingDB.keys())) {
|
||||||
|
const t = dbTimers.get(key);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
flushOne(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
export function seedLocal(key: string, state: any[]) {
|
||||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+566
-100
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
|||||||
|
// One answer to "is this QSL received".
|
||||||
|
//
|
||||||
|
// ADIF's QSL_Rcvd enumeration has BOTH Y and V: Y is "received", V is
|
||||||
|
// "verified" — and V is what a LoTW download writes for a confirmation the ARRL
|
||||||
|
// has validated. Testing for 'Y' alone therefore misses exactly the
|
||||||
|
// confirmations an operator cares most about.
|
||||||
|
//
|
||||||
|
// It showed on screen: the Awards panel had Morocco validated on five bands
|
||||||
|
// while the band/mode matrix beside it showed the entity as merely worked. The
|
||||||
|
// award engine accepted Y or V; every other test in the app accepted Y.
|
||||||
|
//
|
||||||
|
// The Go side has the same rule twice over — award.isYes and qso.ConfirmedValues
|
||||||
|
// — and all three have to agree. If you add a value here, add it there.
|
||||||
|
export function isQSLConfirmed(v: unknown): boolean {
|
||||||
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
|
return s === 'Y' || s === 'V';
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Colouring for QSL / confirmation status letters.
|
||||||
|
//
|
||||||
|
// These columns are read by COLOUR before they are read as letters: an operator
|
||||||
|
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||||
|
// hundred times.
|
||||||
|
//
|
||||||
|
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||||
|
// and a column of coloured boxes would shout louder than the callsign it
|
||||||
|
// belongs to.
|
||||||
|
//
|
||||||
|
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||||
|
// and the contrast stays right on the light ones.
|
||||||
|
|
||||||
|
// ADIF confirmation values, and what they mean to the operator:
|
||||||
|
// Y confirmed / sent
|
||||||
|
// N not sent, not received
|
||||||
|
// R requested — queued, waiting on the other station or the service
|
||||||
|
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||||
|
// good news nor bad, and colouring it would put it in one camp or the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||||
|
// rather than guessed at.
|
||||||
|
export function qslStatusClass(value: unknown): string {
|
||||||
|
switch (String(value ?? '').trim().toUpperCase()) {
|
||||||
|
case 'Y':
|
||||||
|
return 'text-success';
|
||||||
|
case 'N':
|
||||||
|
return 'text-destructive';
|
||||||
|
case 'R':
|
||||||
|
return 'text-info';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||||
|
// status columns already used so the letters stay in a straight column.
|
||||||
|
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||||
|
const c = qslStatusClass(p?.value);
|
||||||
|
return c ? 'font-mono ' + c : 'font-mono';
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
|
// Row colouring for the log grid, by QSL status.
|
||||||
|
//
|
||||||
|
// Four categories, each scoped to the channels the operator cares about —
|
||||||
|
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
||||||
|
// wins, because a contact is usually several of them at once.
|
||||||
|
|
||||||
|
export type RowColorRule = {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
enabled: boolean;
|
||||||
|
channels?: string[]; // empty = every channel
|
||||||
|
};
|
||||||
|
export type RowColorSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
style?: 'bar' | 'tint' | 'both';
|
||||||
|
intensity?: number;
|
||||||
|
bandmap_lotw?: boolean;
|
||||||
|
rules: RowColorRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||||
|
|
||||||
|
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||||
|
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||||
|
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||||
|
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
|
||||||
|
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
|
||||||
|
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
|
||||||
|
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||||
|
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||||
|
// and the one an operator looks for when deciding what to send.
|
||||||
|
// Y or V — see lib/qsl. A LoTW-verified contact is confirmed, and colouring it
|
||||||
|
// as unconfirmed is the same bug the band/mode matrix had.
|
||||||
|
const yes = (v: any) => isQSLConfirmed(v);
|
||||||
|
const owed = (v: any) => {
|
||||||
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
|
return s === 'R' || s === 'Q';
|
||||||
|
};
|
||||||
|
|
||||||
|
const chansOf = (r: RowColorRule): readonly string[] =>
|
||||||
|
r.channels && r.channels.length ? r.channels : CHANNELS;
|
||||||
|
|
||||||
|
function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||||
|
const cs = chansOf(r);
|
||||||
|
switch (r.id) {
|
||||||
|
case 'confirmed':
|
||||||
|
return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
|
||||||
|
case 'sent':
|
||||||
|
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
|
||||||
|
case 'to_send':
|
||||||
|
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
|
||||||
|
case 'worked':
|
||||||
|
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||||
|
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rules IN ORDER — the order is the priority, and the settings panel
|
||||||
|
// shows it numbered so it can be read rather than guessed.
|
||||||
|
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
|
||||||
|
if (!q || !cfg?.rules) return null;
|
||||||
|
for (const r of cfg.rules) {
|
||||||
|
if (r.enabled && ruleMatches(q, r)) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colour is never a fill by default.
|
||||||
|
//
|
||||||
|
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||||
|
// painted, and colour that is always present stops being information. The
|
||||||
|
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||||
|
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
|
||||||
|
if (!cfg?.enabled) return undefined;
|
||||||
|
const rule = matchRowRule(q, cfg);
|
||||||
|
if (!rule?.color) return undefined;
|
||||||
|
|
||||||
|
const style = cfg.style ?? 'bar';
|
||||||
|
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (style === 'tint' || style === 'both') {
|
||||||
|
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||||
|
}
|
||||||
|
if (style === 'bar' || style === 'both') {
|
||||||
|
// inset shadow rather than a border: a border would shift the cells three
|
||||||
|
// pixels on coloured rows only, and the columns would stop lining up.
|
||||||
|
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
|||||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||||
return `5${strength}`;
|
return `5${strength}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
|
||||||
|
// the same per-mode choices (Settings → Modes → RST report lists).
|
||||||
|
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||||
|
|
||||||
|
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
|
||||||
|
export function rstCategory(mode: string): keyof RSTLists {
|
||||||
|
const m = (mode || '').toUpperCase();
|
||||||
|
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||||
|
if (digital.includes(m)) return 'digital';
|
||||||
|
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||||
|
return 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstOptions returns the valid report choices for a mode from the user's
|
||||||
|
// editable lists, with a tiny fallback before they load.
|
||||||
|
export function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||||
|
const cat = rstCategory(mode);
|
||||||
|
const l = lists[cat];
|
||||||
|
if (l && l.length) return l;
|
||||||
|
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Two operator options that change how a spot LOOKS, shared by the DX-cluster
|
||||||
|
// list and the band map so the two panels can never disagree about the same
|
||||||
|
// spot — the lesson already learnt with the marker colours.
|
||||||
|
//
|
||||||
|
// muteWorked — a station already worked gets no colour and no badge. It
|
||||||
|
// stays in the list, it simply stops competing for attention.
|
||||||
|
// slotHighlight — a callsign not yet worked on THIS band and mode is coloured,
|
||||||
|
// whatever the entity says. For an operator filling slots, a
|
||||||
|
// common entity on a new band+mode is the whole point, and the
|
||||||
|
// entity-level status calls it "worked".
|
||||||
|
//
|
||||||
|
// They compose deliberately: mute what is done, light up what is not.
|
||||||
|
|
||||||
|
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
||||||
|
|
||||||
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
|
// switches back is this one flag and the block they came from in App.tsx.
|
||||||
|
//
|
||||||
|
// The saved preferences are left untouched in localStorage rather than cleared:
|
||||||
|
// an operator who had either turned on gets them back exactly as they were the
|
||||||
|
// day the options return, instead of silently starting from off.
|
||||||
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { muteWorked: false, slotHighlight: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
status?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
worked_slot?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
||||||
|
// colour, badge, status text — follows from one decision instead of each panel
|
||||||
|
// re-deriving it.
|
||||||
|
export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions): T {
|
||||||
|
if (!s) return s;
|
||||||
|
let e = s;
|
||||||
|
|
||||||
|
// Slot promotion runs first: a callsign not yet worked on this band and mode
|
||||||
|
// is not done, whatever the entity says, so it earns a status before the mute
|
||||||
|
// below can take its colour away.
|
||||||
|
if (o.slotHighlight && e.worked_slot === false && (!e.status || e.status === 'worked')) {
|
||||||
|
e = { ...e, status: 'new-call' } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute drops the blue already-worked-callsign mark, and NOTHING else.
|
||||||
|
//
|
||||||
|
// It used to blank the status as well, on the theory that a spot bringing no
|
||||||
|
// novelty should stop painting entirely. That was wrong twice over. The status
|
||||||
|
// is what the cluster list reads to DIM a row, so blanking it turned every
|
||||||
|
// quiet grey row bright white — the option made the list louder, not quieter.
|
||||||
|
// And an empty status means "entity not resolved" everywhere else, so muted
|
||||||
|
// spots had to carry a flag saying they did not really mean that.
|
||||||
|
//
|
||||||
|
// Leaving the status alone costs nothing: a worked entity already renders with
|
||||||
|
// no colour and gets dimmed, so removing the blue is the entire job.
|
||||||
|
if (o.muteWorked) {
|
||||||
|
e = { ...e, worked_call: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotIsWorked answers "does this spot bring me nothing?", which is what the
|
||||||
|
// "hide worked" filter is really asking.
|
||||||
|
//
|
||||||
|
// The extra markers are ORTHOGONAL to the entity status — a spot can be a worked
|
||||||
|
// entity AND a new park, prefix, county or grid — so they win. Without that, a
|
||||||
|
// new prefix in an entity worked years ago vanished the moment the filter went
|
||||||
|
// on, which is the opposite of what an operator hunting prefixes wants.
|
||||||
|
export function spotIsWorked(e: Entry): boolean {
|
||||||
|
if (!e) return false;
|
||||||
|
if (e.new_pota || e.new_county || e.new_pfx || e.new_grid) return false;
|
||||||
|
return !!e.worked_call || e.status === 'worked';
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// The "extra" spot markers — the ones ORTHOGONAL to the entity status.
|
||||||
|
//
|
||||||
|
// A spot's entity status (new DXCC / new band / new slot / worked) answers one
|
||||||
|
// question. These answer others that can be true at the same time: the park is
|
||||||
|
// new even if the entity is worked, the county is new even if the band is not.
|
||||||
|
// Both the DX-cluster list and the band map show them, and they have to agree —
|
||||||
|
// the same fact must not be green in one panel and violet in the next, so the
|
||||||
|
// colours live HERE and nowhere else. This is also the table a per-marker colour
|
||||||
|
// setting will drive, which is why it is a table rather than three constants.
|
||||||
|
//
|
||||||
|
// Colour choices:
|
||||||
|
// POTA green — parks; the association is worth keeping
|
||||||
|
// county violet — a chart hue, because every semantic token was already
|
||||||
|
// spoken for: red, orange and yellow are the entity
|
||||||
|
// statuses, blue is "callsign worked", green is POTA
|
||||||
|
// worked blue — matches the WKD-CALL badge the cluster list has always used
|
||||||
|
// prefix yellow — unchanged
|
||||||
|
// grid magenta — the last hue in the categorical set that is not already
|
||||||
|
// spoken for here and does not read as a status; a grid is
|
||||||
|
// never urgent the way a new entity is
|
||||||
|
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
|
||||||
|
|
||||||
|
export type SpotMarker = {
|
||||||
|
key: SpotMarkerKey;
|
||||||
|
colour: string; // a CSS colour — a var() reference, so it follows the theme
|
||||||
|
labelKey: string; // i18n key, short form (badge / legend)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order is the display order, in both panels.
|
||||||
|
export const SPOT_MARKERS: SpotMarker[] = [
|
||||||
|
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
|
||||||
|
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||||
|
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||||
|
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
||||||
|
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const markerColour = (key: SpotMarkerKey): string =>
|
||||||
|
SPOT_MARKERS.find((m) => m.key === key)?.colour ?? 'var(--muted-foreground)';
|
||||||
|
|
||||||
|
// activeMarkers returns the markers set on a status entry, in display order.
|
||||||
|
export function activeMarkers(e: Record<string, unknown> | undefined): SpotMarker[] {
|
||||||
|
if (!e) return [];
|
||||||
|
return SPOT_MARKERS.filter((m) => !!e[m.key]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Sunrise / sunset for a station, in UTC.
|
||||||
|
//
|
||||||
|
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||||
|
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||||
|
// never disagree, and there is a whole comment block over there about a sign
|
||||||
|
// error that produced six months of plausible-looking wrong answers. One source
|
||||||
|
// of astronomy, one place to be wrong.
|
||||||
|
//
|
||||||
|
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||||
|
// time would raise the question "local to whom — me or the DX?".
|
||||||
|
import { sunPosition } from './greyline';
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||||
|
// the horizon plus atmospheric refraction.
|
||||||
|
const H0 = -0.833;
|
||||||
|
|
||||||
|
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||||
|
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const lat = latDeg * DEG;
|
||||||
|
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SunTimes = {
|
||||||
|
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||||
|
set: string;
|
||||||
|
polarDay: boolean; // Sun stays up all day
|
||||||
|
polarNight: boolean; // Sun never comes up
|
||||||
|
};
|
||||||
|
|
||||||
|
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||||
|
// crosses the rise/set altitude.
|
||||||
|
//
|
||||||
|
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||||
|
// special-casing for the polar cases and for days where the declination shifts
|
||||||
|
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||||
|
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||||
|
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||||
|
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||||
|
// result is accurate to well under a minute despite the coarse scan.
|
||||||
|
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||||
|
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||||
|
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||||
|
|
||||||
|
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
const minute = 60000;
|
||||||
|
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||||
|
const anyUp = prev > H0;
|
||||||
|
let anyDown = prev <= H0;
|
||||||
|
let sawUp = anyUp;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1440; i++) {
|
||||||
|
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||||
|
if (alt > H0) sawUp = true; else anyDown = true;
|
||||||
|
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||||
|
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||||
|
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
}
|
||||||
|
prev = alt;
|
||||||
|
}
|
||||||
|
if (!out.rise && !out.set) {
|
||||||
|
out.polarDay = sawUp && !anyDown;
|
||||||
|
out.polarNight = !sawUp;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frac is where between two samples the altitude crosses H0.
|
||||||
|
function frac(a: number, b: number): number {
|
||||||
|
const d = b - a;
|
||||||
|
return d === 0 ? 0 : (H0 - a) / d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
|
||||||
|
// Note).
|
||||||
|
//
|
||||||
|
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
|
||||||
|
// operator, and in whatever case the other logger stored it in an imported
|
||||||
|
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
|
||||||
|
// and sorting or reading a QTH column becomes a mess.
|
||||||
|
//
|
||||||
|
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
|
||||||
|
// value while the operator is still typing a word fights them mid-word (see
|
||||||
|
// the controlled-input note in CLAUDE.md).
|
||||||
|
|
||||||
|
// titleCase upper-cases the first letter of every word and lower-cases the
|
||||||
|
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
|
||||||
|
// "o'brien" → "O'Brien".
|
||||||
|
export function titleCase(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/(^|[\s\-'’/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentenceCase upper-cases the first letter and leaves everything after it as
|
||||||
|
// typed. A comment routinely carries callsigns, modes and abbreviations
|
||||||
|
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
|
||||||
|
// why this is NOT titleCase.
|
||||||
|
export function sentenceCase(s: string): string {
|
||||||
|
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||||
import { writeUiPref } from './uiPref';
|
import { writeUiPref } from './uiPref';
|
||||||
|
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
||||||
|
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||||
|
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||||
|
// taste one, and listing it among the moods buries it.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
||||||
|
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
||||||
|
'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
const DEFAULT: ThemeChoice = 'light-warm';
|
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||||
|
// looked at for hours; every other logger defaults the same way. Graphite
|
||||||
|
// specifically, because that is what 'auto' already resolves to for a dark
|
||||||
|
// system — so the two paths agree instead of landing on different darks.
|
||||||
|
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||||
|
|
||||||
function systemDark(): boolean {
|
function systemDark(): boolean {
|
||||||
@@ -49,13 +59,52 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
|||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||||
|
// Set once the operator changes the theme by hand, so the self-heal below
|
||||||
|
// never clobbers a fresh choice with a value it read a moment earlier.
|
||||||
|
const userPicked = useRef(false);
|
||||||
|
|
||||||
const setTheme = useCallback((t: ThemeChoice) => {
|
const setTheme = useCallback((t: ThemeChoice) => {
|
||||||
|
userPicked.current = true;
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
applyThemeToDom(t);
|
applyThemeToDom(t);
|
||||||
writeUiPref(LS_KEY, t);
|
writeUiPref(LS_KEY, t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
||||||
|
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
||||||
|
// while the backend was still starting (settings store not wired yet → GetUIPref
|
||||||
|
// returned "" with no error, so nothing was restored) — the "restart lands on
|
||||||
|
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
||||||
|
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let tries = 0;
|
||||||
|
const load = () => {
|
||||||
|
tries += 1;
|
||||||
|
GetUIPref(LS_KEY).then((raw) => {
|
||||||
|
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||||
|
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||||
|
// Either way we're done — do NOT retry (retrying only matters while the
|
||||||
|
// backend is still starting, which now surfaces as a rejected promise).
|
||||||
|
if (cancelled || userPicked.current) return;
|
||||||
|
const v = raw as ThemeChoice;
|
||||||
|
if (v && ALL.includes(v)) {
|
||||||
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||||
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||||
|
setThemeState(v);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Settings store not ready yet — a brief startup window before the backend
|
||||||
|
// has opened the local DB and built the store (the frontend can query it
|
||||||
|
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||||
|
// lost to the light default after an update cleared localStorage.
|
||||||
|
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'auto') return;
|
if (theme !== 'auto') return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||||
// identical after a copy.
|
// identical after a copy.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
// localStorage copy is just a fast, synchronous cache).
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
@@ -17,15 +17,37 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.autofocusWB', // auto-focus Worked-before
|
'opslog.autofocusWB', // auto-focus Worked-before
|
||||||
'hamlog.filterPresets', // Filter Builder saved presets
|
'hamlog.filterPresets', // Filter Builder saved presets
|
||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
|
'opslog.showChaseNew', // Chase New panel shown next to the keyers
|
||||||
|
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||||
|
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||||
|
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||||
|
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||||
|
// Cluster filter selections — restored on reopen.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||||
|
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||||
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
|
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||||
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
// through this global path would fight that per-profile scoping.
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
@@ -51,5 +73,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
|||||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
export function writeUiPref(key: string, value: string): void {
|
export function writeUiPref(key: string, value: string): void {
|
||||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
SetUIPref(key, value).catch((e: any) => {
|
||||||
|
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||||
|
// reads some of these keys too (digital-mode grouping decides how the
|
||||||
|
// cluster judges a slot). A write that reaches localStorage and not the
|
||||||
|
// database makes the two disagree, and used to do so in silence.
|
||||||
|
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import App from './App'
|
|||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
import { ThemeProvider, initTheme } from './lib/theme'
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||||
|
import { reloadDateFormat } from './lib/dateFormat'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
|
|||||||
// Stamp the persisted theme onto <html> before render so the correct
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
// palette is applied immediately (portable pref hydrated just above).
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
initTheme()
|
initTheme()
|
||||||
|
// The date format module read localStorage at import time, which is BEFORE
|
||||||
|
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||||
|
// copied data/ folder would show ISO until the next launch.
|
||||||
|
reloadDateFormat()
|
||||||
|
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||||
|
// rejected promises. Installed before the first render so a fault during
|
||||||
|
// startup is reported too.
|
||||||
|
installGlobalErrorLogging()
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App/>
|
<App/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,6 +92,207 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||||
|
[data-theme="light-cool"] {
|
||||||
|
--background: #f4f6f8; /* cool blue-white */
|
||||||
|
--foreground: #0f172a; /* slate-900 */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #0f172a;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #0f172a;
|
||||||
|
--primary: #2563eb; /* blue-600 */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e2e8f0; /* slate-200 */
|
||||||
|
--secondary-foreground: #0f172a;
|
||||||
|
--muted: #e6ebf1; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||||
|
--accent: #dbeafe; /* blue-100 */
|
||||||
|
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #d3dae2; /* cool slate border */
|
||||||
|
--input: #d3dae2;
|
||||||
|
--ring: #3b82f6; /* blue-500 focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #e2e8f0;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c2d1;
|
||||||
|
--scrollbar-thumb-hover: #8fa1b5;
|
||||||
|
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--background: #eef1ec; /* pale sage */
|
||||||
|
--foreground: #1a2420; /* deep pine-ink */
|
||||||
|
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||||
|
--card-foreground: #1a2420;
|
||||||
|
--popover: #f8faf6;
|
||||||
|
--popover-foreground: #1a2420;
|
||||||
|
--primary: #2f855a; /* deep sage green */
|
||||||
|
--primary-foreground: #f0fdf4;
|
||||||
|
--secondary: #dde5db;
|
||||||
|
--secondary-foreground: #1a2420;
|
||||||
|
--muted: #dde5db; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||||
|
--accent: #d7e8d5; /* soft green */
|
||||||
|
--accent-foreground: #1e4634; /* forest-900 */
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #cdd8ce; /* soft sage border */
|
||||||
|
--input: #cdd8ce;
|
||||||
|
--ring: #38a169; /* green focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #dde5db;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b3c2b3;
|
||||||
|
--scrollbar-thumb-hover: #91a591;
|
||||||
|
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||||
|
[data-theme="dim-slate"] {
|
||||||
|
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||||
|
--foreground: #eceef2;
|
||||||
|
--card: #3d4552; /* lifted panel */
|
||||||
|
--card-foreground: #eceef2;
|
||||||
|
--popover: #3d4552;
|
||||||
|
--popover-foreground: #eceef2;
|
||||||
|
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||||
|
--primary-foreground: #241206;
|
||||||
|
--secondary: #454e5c;
|
||||||
|
--secondary-foreground: #eceef2;
|
||||||
|
--muted: #414a58; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||||
|
--accent: #495468; /* hover / selection tint */
|
||||||
|
--accent-foreground: #dce3ef;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #1a0808;
|
||||||
|
--destructive-muted: #3f1f1f;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #4e5867;
|
||||||
|
--input: #4e5867;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #06231a;
|
||||||
|
--success-muted: #1c3d34;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #2a5f4c;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #241a06;
|
||||||
|
--warning-muted: #41371a;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #5e4d22;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #242006;
|
||||||
|
--caution-muted: #403b1a;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #5b5222;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #260a11;
|
||||||
|
--danger-muted: #421f28;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #63303c;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #061f2b;
|
||||||
|
--info-muted: #16384a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #1d5670;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #4a5262;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #565f70;
|
||||||
|
--scrollbar-thumb-hover: #6b7588;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||||
[data-theme="dark-warm"] {
|
[data-theme="dark-warm"] {
|
||||||
--background: #221d18;
|
--background: #221d18;
|
||||||
@@ -294,6 +495,424 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 5: Indigo — deep blue-violet, electric accent ----------------
|
||||||
|
The first seven themes are all neutral (beige, slate, graphite, black) with an
|
||||||
|
orange accent, so "another dark theme" only ever meant another grey. These
|
||||||
|
four carry a real hue in the SURFACES, not just in the accent, and each takes
|
||||||
|
a different primary — violet, cyan, pink, indigo — so they are told apart at a
|
||||||
|
glance in the picker.
|
||||||
|
|
||||||
|
Every one keeps the same contract: the semantic colours (success / warning /
|
||||||
|
caution / danger / info) must stay recognisable as themselves. A logger is
|
||||||
|
read for hours and "red = a problem" cannot become a decorative choice, so the
|
||||||
|
hue budget is spent on the surfaces and the primary, never on the meanings. */
|
||||||
|
[data-theme="dark-indigo"] {
|
||||||
|
--background: #0e0f1f;
|
||||||
|
--foreground: #e4e5f2;
|
||||||
|
--card: #181a30;
|
||||||
|
--card-foreground: #e4e5f2;
|
||||||
|
--popover: #181a30;
|
||||||
|
--popover-foreground: #e4e5f2;
|
||||||
|
--primary: #7c6cff; /* electric violet */
|
||||||
|
--primary-foreground: #0b0a1a;
|
||||||
|
--secondary: #222542;
|
||||||
|
--secondary-foreground: #e4e5f2;
|
||||||
|
--muted: #1e2038;
|
||||||
|
--muted-foreground: #9d9fbe;
|
||||||
|
--accent: #2a2d51;
|
||||||
|
--accent-foreground: #c7c9ee;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #34131c;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #2a2d4c;
|
||||||
|
--input: #2a2d4c;
|
||||||
|
--ring: #9b8dff;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #052018;
|
||||||
|
--success-muted: #0e2f27;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #165241;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #37151f;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #562533;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #051d29;
|
||||||
|
--info-muted: #0b2a3a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #12475c;
|
||||||
|
|
||||||
|
/* The entity ramp moves to CYAN here: violet is the primary, and a violet
|
||||||
|
"entity confirmed" cell would read as a button. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #22d3ee;
|
||||||
|
--mx-dx-work: #1b6b7c;
|
||||||
|
--mx-none: #2c2f4d;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #383c63;
|
||||||
|
--scrollbar-thumb-hover: #4c5182;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(228, 229, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 6: Ocean — deep teal, cyan accent --------------------------- */
|
||||||
|
[data-theme="dark-teal"] {
|
||||||
|
--background: #061c21;
|
||||||
|
--foreground: #dcecee;
|
||||||
|
--card: #0d2c33;
|
||||||
|
--card-foreground: #dcecee;
|
||||||
|
--popover: #0d2c33;
|
||||||
|
--popover-foreground: #dcecee;
|
||||||
|
--primary: #22d3ee; /* cyan */
|
||||||
|
--primary-foreground: #041d24;
|
||||||
|
--secondary: #143b44;
|
||||||
|
--secondary-foreground: #dcecee;
|
||||||
|
--muted: #10333b;
|
||||||
|
--muted-foreground: #92b4ba;
|
||||||
|
--accent: #15424c;
|
||||||
|
--accent-foreground: #a5d8e0;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #35161a;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #1a4652;
|
||||||
|
--input: #1a4652;
|
||||||
|
--ring: #5fe3f5;
|
||||||
|
|
||||||
|
--success: #4ade80;
|
||||||
|
--success-foreground: #04220f;
|
||||||
|
--success-muted: #0e3020;
|
||||||
|
--success-muted-foreground: #86efac;
|
||||||
|
--success-border: #17593a;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #312d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4a4315;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #38161f;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #572634;
|
||||||
|
|
||||||
|
/* Info moves to a BLUE: a cyan "info" would be the primary colour again. */
|
||||||
|
--info: #7aa2f7;
|
||||||
|
--info-foreground: #04122e;
|
||||||
|
--info-muted: #16233f;
|
||||||
|
--info-muted-foreground: #a9c2fb;
|
||||||
|
--info-border: #2b3d66;
|
||||||
|
|
||||||
|
--mx-call-conf: #4ade80;
|
||||||
|
--mx-call-work: #2b7a4d;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9c;
|
||||||
|
--mx-none: #1c4550;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #235a68;
|
||||||
|
--scrollbar-thumb-hover: #31788a;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(220, 236, 238, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 7: Plum — aubergine, magenta accent ------------------------- */
|
||||||
|
[data-theme="dark-plum"] {
|
||||||
|
--background: #180f1e;
|
||||||
|
--foreground: #eee0f0;
|
||||||
|
--card: #251830;
|
||||||
|
--card-foreground: #eee0f0;
|
||||||
|
--popover: #251830;
|
||||||
|
--popover-foreground: #eee0f0;
|
||||||
|
--primary: #f472b6; /* magenta-pink */
|
||||||
|
--primary-foreground: #250518;
|
||||||
|
--secondary: #33203f;
|
||||||
|
--secondary-foreground: #eee0f0;
|
||||||
|
--muted: #2b1b36;
|
||||||
|
--muted-foreground: #b39dbd;
|
||||||
|
--accent: #3b2648;
|
||||||
|
--accent-foreground: #dcc2e6;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #3a1620;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #3a2547;
|
||||||
|
--input: #3a2547;
|
||||||
|
--ring: #f9a8d4;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #052018;
|
||||||
|
--success-muted: #10302a;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #1a5745;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #352a12;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #524019;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #342e12;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4e4519;
|
||||||
|
|
||||||
|
/* Danger goes RED here, not rose: a rose "danger" beside a pink primary is
|
||||||
|
indistinguishable, and this is the one colour that must never be missed. */
|
||||||
|
--danger: #f43f3f;
|
||||||
|
--danger-foreground: #2a0606;
|
||||||
|
--danger-muted: #3d1418;
|
||||||
|
--danger-muted-foreground: #fca5a5;
|
||||||
|
--danger-border: #5c2128;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #051d29;
|
||||||
|
--info-muted: #0f2b3a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #16485d;
|
||||||
|
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #60a5fa;
|
||||||
|
--mx-dx-work: #35538c;
|
||||||
|
--mx-none: #3a2647;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #4a3159;
|
||||||
|
--scrollbar-thumb-hover: #644176;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(238, 224, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 8: Nordic light — crisp cool white, indigo accent ----------- */
|
||||||
|
[data-theme="light-nordic"] {
|
||||||
|
--background: #eef1f7;
|
||||||
|
--foreground: #1b2130;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #1b2130;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #1b2130;
|
||||||
|
--primary: #4f46e5; /* indigo */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #dfe4ee;
|
||||||
|
--secondary-foreground: #1b2130;
|
||||||
|
--muted: #e2e7f0;
|
||||||
|
--muted-foreground: #4a5468;
|
||||||
|
--accent: #dcdcfb;
|
||||||
|
--accent-foreground: #312a91;
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #c9d1e0;
|
||||||
|
--input: #c9d1e0;
|
||||||
|
--ring: #6366f1;
|
||||||
|
|
||||||
|
--success: #15803d;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #14532d;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #b45309;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #a16207;
|
||||||
|
--caution-foreground: #ffffff;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #fee2e2;
|
||||||
|
--danger-muted-foreground: #991b1b;
|
||||||
|
--danger-border: #fca5a5;
|
||||||
|
|
||||||
|
--info: #0369a1;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Entity ramp shifts to TEAL: indigo is the primary here. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #86efac;
|
||||||
|
--mx-dx-conf: #0f766e;
|
||||||
|
--mx-dx-work: #99f6e4;
|
||||||
|
--mx-none: #dde3ec;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c0d2;
|
||||||
|
--scrollbar-thumb-hover: #93a0b8;
|
||||||
|
--card-shadow: 0 1px 2px rgba(27, 33, 48, 0.06), 0 0 0 1px rgba(27, 33, 48, 0.03);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1e: Sahara — Warm light, taken a step deeper --------------------
|
||||||
|
Warm light's cream is kept for panels and the entry strip (#faf6eb, the one
|
||||||
|
surface an operator stares at while typing a call), but the page behind them
|
||||||
|
and every toolbar, tab bar and table header go one shade further into the
|
||||||
|
sand. That was the whole request: same family, just a little darker, so the
|
||||||
|
cream panels lift clearly instead of blending into the page.
|
||||||
|
Ink is a deep warm brown rather than black: pure black on sand glares.
|
||||||
|
The accent moves from Warm light's burnt orange to ochre to keep the two
|
||||||
|
themes apart at a glance in the theme picker. ---------------------------- */
|
||||||
|
[data-theme="sahara"] {
|
||||||
|
--background: #e6ded0; /* parchment — the page */
|
||||||
|
--foreground: #3a3227; /* warm brown-ink */
|
||||||
|
--card: #f3eee2; /* panels, entry strip, log rows */
|
||||||
|
--card-foreground: #3a3227;
|
||||||
|
--popover: #f3eee2;
|
||||||
|
--popover-foreground: #3a3227;
|
||||||
|
--primary: #c4501e; /* terracotta — buttons and the frequency */
|
||||||
|
--primary-foreground: #fdf8f0;
|
||||||
|
--secondary: #ded5c4;
|
||||||
|
--secondary-foreground: #3a3227;
|
||||||
|
--muted: #ded5c4; /* toolbars / tab bar / table headers */
|
||||||
|
--muted-foreground: #6a5f4e; /* readable on both parchments */
|
||||||
|
--accent: #ecd9c0; /* warm highlight */
|
||||||
|
--accent-foreground: #7a3410;
|
||||||
|
--destructive: #b3401b;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #f7e4da;
|
||||||
|
--destructive-muted-foreground: #8f3315;
|
||||||
|
--border: #cdc2ae; /* one step down, so edges read without lines */
|
||||||
|
--input: #cdc2ae;
|
||||||
|
--ring: #c4501e; /* the accent, so focus matches the buttons */
|
||||||
|
|
||||||
|
--success: #15803d;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcf3e1;
|
||||||
|
--success-muted-foreground: #14532d;
|
||||||
|
--success-border: #86d99f;
|
||||||
|
|
||||||
|
--warning: #b45309;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fbeacd;
|
||||||
|
--warning-muted-foreground: #7c3d05;
|
||||||
|
--warning-border: #e8b96a;
|
||||||
|
|
||||||
|
--caution: #a16207;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #f7edcb;
|
||||||
|
--caution-muted-foreground: #713f12;
|
||||||
|
--caution-border: #e0c069;
|
||||||
|
|
||||||
|
--danger: #be123c;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #fbe3e8;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #ee9aac;
|
||||||
|
|
||||||
|
--info: #0369a1;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0eef6;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #86c5e4;
|
||||||
|
|
||||||
|
/* Band/mode matrix — the app's blues kept, on a sand neutral so an empty slot
|
||||||
|
recedes into the grid instead of punching a hole in it. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #7fd6a4;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #ded5c4;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #c2b6a0;
|
||||||
|
--scrollbar-thumb-hover: #a5977e;
|
||||||
|
--card-shadow: 0 1px 2px rgba(58, 50, 39, 0.06), 0 0 0 1px rgba(58, 50, 39, 0.03);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
|
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||||
|
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||||
|
automatic inversion.
|
||||||
|
|
||||||
|
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||||
|
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||||
|
shows.
|
||||||
|
|
||||||
|
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||||
|
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||||
|
recedes toward the surface instead of glowing. */
|
||||||
|
:root,
|
||||||
|
[data-theme="light-warm"],
|
||||||
|
[data-theme="light-cool"],
|
||||||
|
[data-theme="light-sage"],
|
||||||
|
[data-theme="light-nordic"],
|
||||||
|
[data-theme="sahara"] {
|
||||||
|
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||||
|
--chart-2: #1baf7a; /* aqua */
|
||||||
|
--chart-3: #eda100; /* yellow */
|
||||||
|
--chart-4: #008300; /* green */
|
||||||
|
--chart-5: #4a3aa7; /* violet */
|
||||||
|
--chart-6: #e34948; /* red */
|
||||||
|
--chart-7: #e87ba4; /* magenta */
|
||||||
|
--chart-8: #eb6834; /* orange */
|
||||||
|
|
||||||
|
--chart-seq-1: #cde2fb;
|
||||||
|
--chart-seq-2: #9ec5f4;
|
||||||
|
--chart-seq-3: #6da7ec;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #256abf;
|
||||||
|
--chart-seq-6: #104281;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dim-slate"],
|
||||||
|
[data-theme="dark-warm"],
|
||||||
|
[data-theme="dark-graphite"],
|
||||||
|
[data-theme="high-contrast"],
|
||||||
|
[data-theme="dark-indigo"],
|
||||||
|
[data-theme="dark-teal"],
|
||||||
|
[data-theme="dark-plum"] {
|
||||||
|
--chart-1: #3987e5;
|
||||||
|
--chart-2: #199e70;
|
||||||
|
--chart-3: #c98500;
|
||||||
|
--chart-4: #008300;
|
||||||
|
--chart-5: #9085e9;
|
||||||
|
--chart-6: #e66767;
|
||||||
|
--chart-7: #d55181;
|
||||||
|
--chart-8: #d95926;
|
||||||
|
|
||||||
|
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||||
|
--chart-seq-1: #0d366b;
|
||||||
|
--chart-seq-2: #184f95;
|
||||||
|
--chart-seq-3: #256abf;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #6da7ec;
|
||||||
|
--chart-seq-6: #9ec5f4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||||
the generated utilities reference var(--…) directly, so overriding a var in
|
the generated utilities reference var(--…) directly, so overriding a var in
|
||||||
a [data-theme] block re-skins every utility at runtime. */
|
a [data-theme] block re-skins every utility at runtime. */
|
||||||
@@ -394,3 +1013,38 @@
|
|||||||
border: 2px solid var(--background);
|
border: 2px solid var(--background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||||
|
|
||||||
|
/* ── Frameless window: drag region ──────────────────────────────────────────
|
||||||
|
The OS title bar is gone (main.go, Frameless), so the app header IS the title
|
||||||
|
bar and must be draggable. Everything interactive inside it opts OUT: a drag
|
||||||
|
region swallows clicks, and without this the menus and toolbar buttons would
|
||||||
|
move the window instead of doing their job. Opting out by ROLE rather than
|
||||||
|
listing each child keeps a future button working without anyone remembering
|
||||||
|
this rule. */
|
||||||
|
.app-dragregion {
|
||||||
|
--wails-draggable: drag;
|
||||||
|
}
|
||||||
|
.app-dragregion button,
|
||||||
|
.app-dragregion a,
|
||||||
|
.app-dragregion input,
|
||||||
|
.app-dragregion select,
|
||||||
|
.app-dragregion textarea,
|
||||||
|
.app-dragregion nav,
|
||||||
|
.app-dragregion [role='button'],
|
||||||
|
.app-dragregion [role='menu'],
|
||||||
|
.app-dragregion [data-no-drag] {
|
||||||
|
--wails-draggable: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
|
||||||
|
disappears on the dark themes. Invert it there — the control itself is worth
|
||||||
|
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||||
|
help. */
|
||||||
|
Every temporal input carries the same glyph, so the rule names them all:
|
||||||
|
datetime-local was left out at first and its icon stayed invisible. */
|
||||||
|
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'],
|
||||||
|
[data-theme='dark-indigo'], [data-theme='dark-teal'], [data-theme='dark-plum'])
|
||||||
|
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1) brightness(1.8);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.18';
|
export const APP_VERSION = '0.25.3';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+431
-11
@@ -5,20 +5,36 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {acom} from '../models';
|
||||||
|
import {catemu} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
|
import {bandopen} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {pskr} from '../models';
|
||||||
|
import {spe} from '../models';
|
||||||
|
import {solar} from '../models';
|
||||||
|
import {tunergenius} from '../models';
|
||||||
|
import {webpub} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {contest} from '../models';
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
|
import {lotwusers} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
import {scp} from '../models';
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ADIFExtraFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
@@ -28,15 +44,41 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
|
|
||||||
|
export function AudioStartMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStartTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioTXActive():Promise<boolean>;
|
||||||
|
|
||||||
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|
||||||
@@ -44,14 +86,26 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
|
||||||
|
|
||||||
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
|
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CIVTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function CancelConfirmations():Promise<void>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -120,19 +174,39 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DismissQSLViaRepair():Promise<void>;
|
||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadScp():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
@@ -156,8 +230,14 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
@@ -166,12 +246,16 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
@@ -184,10 +268,24 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
@@ -200,6 +298,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetPower(arg1:number):Promise<void>;
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
@@ -208,14 +310,30 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -224,12 +342,32 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexStopCW():Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetACOMStatus():Promise<acom.Status>;
|
||||||
|
|
||||||
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||||
|
|
||||||
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
|
|
||||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
@@ -238,7 +376,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
|||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string):Promise<award.Result>;
|
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||||
|
|
||||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
@@ -248,24 +386,44 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
|||||||
|
|
||||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
|
|
||||||
|
export function GetBandOpenSettings():Promise<main.BandOpenSettings>;
|
||||||
|
|
||||||
|
export function GetBandOpenings():Promise<Array<bandopen.Opening>>;
|
||||||
|
|
||||||
export function GetCATSettings():Promise<main.CATSettings>;
|
export function GetCATSettings():Promise<main.CATSettings>;
|
||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
|
export function GetChaseNew():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetChaseNewGrids():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
export function GetClusterAutoConnect():Promise<boolean>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
|
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
@@ -286,56 +444,114 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
|||||||
|
|
||||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||||
|
|
||||||
|
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
|
export function GetLinkedAmps():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
|
||||||
|
|
||||||
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetOperators():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
|
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||||
|
|
||||||
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSelfSpotSettings():Promise<main.SelfSpotSettings>;
|
||||||
|
|
||||||
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
|
|
||||||
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
|
export function GetSpotTTLMinutes():Promise<number>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetTrackedAwards():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWebPublishConfig():Promise<webpub.Config>;
|
||||||
|
|
||||||
|
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||||
|
|
||||||
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
|
|
||||||
|
export function GetWorkedCallVariants():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function IcomRefresh():Promise<void>;
|
export function IcomRefresh():Promise<void>;
|
||||||
@@ -424,12 +640,20 @@ export function IcomStopCW():Promise<void>;
|
|||||||
|
|
||||||
export function IcomTune():Promise<void>;
|
export function IcomTune():Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
|
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function KenwoodStopCW():Promise<void>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -448,6 +672,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
|||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
@@ -462,10 +688,26 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
|
|
||||||
|
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function MotorTuneKHz(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
@@ -504,6 +746,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
|||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||||
@@ -512,6 +756,14 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyDown():Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyUp():Promise<void>;
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -522,6 +774,10 @@ export function PickSaveDatabase():Promise<string>;
|
|||||||
|
|
||||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function PublishLogNow():Promise<string>;
|
||||||
|
|
||||||
|
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
|
||||||
|
|
||||||
export function QSLDefaultTemplateID():Promise<number>;
|
export function QSLDefaultTemplateID():Promise<number>;
|
||||||
|
|
||||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||||
@@ -536,6 +792,8 @@ export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
|
|||||||
|
|
||||||
export function QSLGetTemplate(arg1:number):Promise<string>;
|
export function QSLGetTemplate(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLListOtherProfileTemplates():Promise<Array<main.QSLForeignTemplate>>;
|
||||||
|
|
||||||
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
||||||
|
|
||||||
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
||||||
@@ -556,24 +814,48 @@ export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
|
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
|
||||||
|
|
||||||
|
export function QSLViaRepairStatus():Promise<main.QSLViaRepairStatus>;
|
||||||
|
|
||||||
export function QSOAudioBegin():Promise<boolean>;
|
export function QSOAudioBegin():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
export function QSOAudioCancel():Promise<void>;
|
||||||
|
|
||||||
|
export function QSOAudioManualReady():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioManualStart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir():Promise<void>;
|
||||||
|
|
||||||
export function QSOAudioResetClock():Promise<boolean>;
|
export function QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioResume():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioStop():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
|
export function RefreshYaesuPanel():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameLogbook(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
|
export function RepairQSLVia():Promise<main.QSLViaRepairResult>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|
||||||
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
||||||
@@ -588,18 +870,36 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
|
export function RevealDataFolder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function RotatorGoToPath(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
|
|
||||||
export function RotatorStop():Promise<void>;
|
export function RotatorStop():Promise<void>;
|
||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SMTPConfigured():Promise<boolean>;
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
@@ -612,6 +912,8 @@ export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
|||||||
|
|
||||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveBandOpenSettings(arg1:main.BandOpenSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveCabrilloFile():Promise<string>;
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
@@ -624,6 +926,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
|||||||
|
|
||||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
@@ -642,16 +946,32 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveWebPublishConfig(arg1:webpub.Config):Promise<void>;
|
||||||
|
|
||||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function ScpLookup(arg1:string):Promise<scp.Result>;
|
||||||
|
|
||||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||||
@@ -662,42 +982,114 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SendLogToDeveloper():Promise<void>;
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNew(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCompactHeight(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
|
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuMicGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuModeRaw(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuRFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSquelch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEQSLUpload():Promise<string>;
|
export function TestEQSLUpload():Promise<string>;
|
||||||
@@ -716,10 +1108,28 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TestWebPublishFTP(arg1:webpub.Config):Promise<string>;
|
||||||
|
|
||||||
|
export function TuneYaesuATU():Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusAutotune():Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function UILog(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
@@ -738,6 +1148,8 @@ export function UploadCallsign(arg1:string):Promise<string>;
|
|||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
||||||
|
|
||||||
export function WinkeyerBackspace():Promise<void>;
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|
||||||
export function WinkeyerConnect():Promise<void>;
|
export function WinkeyerConnect():Promise<void>;
|
||||||
@@ -750,4 +1162,12 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function WinkeyerStop():Promise<void>;
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|
||||||
|
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function YaesuStopCW():Promise<void>;
|
||||||
|
|
||||||
|
export function YaesuZeroIn():Promise<void>;
|
||||||
|
|||||||
+842
-22
File diff suppressed because it is too large
Load Diff
+1971
-12
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user