diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java
index 20de7ba..f7053a0 100644
--- a/src/main/java/kst4contest/controller/ChatController.java
+++ b/src/main/java/kst4contest/controller/ChatController.java
@@ -2179,32 +2179,39 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
/**
- * Returns true when the member's gross field is not worked on at least one enabled
- * station band. This is the predicate behind the "new locator" filter.
+ * Returns true when the member's four-character grid square has already been
+ * worked on any band.
+ *
+ *
This method intentionally ignores the currently enabled own bands. The grid
+ * status in the user list is meant to be a simple and robust any-band indicator.
*
* @param member member to inspect
- * @return true if the station is still a new locator on any enabled band
+ * @return true if the gross grid square was worked on any band
*/
- public boolean isNewLocatorOnAnyEnabledBand(ChatMember member) {
+ public boolean isGridSquareWorkedAny(ChatMember member) {
if (member == null || member.getQra() == null) {
return false;
}
- EnumSet enabledBands = reachabilityService == null
- ? getMyEnabledBandsFromPrefs(chatPreferences)
- : reachabilityService.getEnabledStationBands();
+ return workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
+ }
- if (enabledBands.isEmpty()) {
+ /**
+ * Returns true when the member's four-character grid square has not been worked
+ * on any band yet.
+ *
+ * This is the predicate behind the optional "Only new grids" filter. It no
+ * longer depends on active station band settings.
+ *
+ * @param member member to inspect
+ * @return true if the gross grid square is still new
+ */
+ public boolean isNewGridSquare(ChatMember member) {
+ if (member == null || member.getQra() == null) {
return false;
}
- for (Band band : enabledBands) {
- if (!workedGrossFieldCache.isGrossFieldWorked(band, member.getQra())) {
- return true;
- }
- }
-
- return false;
+ return !workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
}
public long getCurrentEpochTime() {
diff --git a/src/main/java/kst4contest/controller/DBController.java b/src/main/java/kst4contest/controller/DBController.java
index 3ec64e4..583d588 100644
--- a/src/main/java/kst4contest/controller/DBController.java
+++ b/src/main/java/kst4contest/controller/DBController.java
@@ -682,8 +682,7 @@ public class DBController {
String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore);
if (workedBandColumnName == null) {
- System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!");
- return false;
+ return helper_updateWorkedAnyOnChatMember(chatMemberToStore);
}
// String updateWorkedSql =
@@ -717,6 +716,47 @@ public class DBController {
}
}
+ /**
+ * Updates only the global worked-any flag of a stored chatmember row.
+ *
+ * This is used when an external logger confirms that a station was worked,
+ * but the software cannot reliably map the QSO to one of the persisted band
+ * columns. The UI status "x" is based on this global worked flag, so this method
+ * makes no-band or unsupported-band log entries persistent as worked-any.
+ *
+ * @param chatMemberToStore chatmember that contains the worked call and optional locator
+ * @return true if an existing database row was updated
+ * @throws SQLException if the database write fails
+ */
+ private synchronized boolean helper_updateWorkedAnyOnChatMember(ChatMember chatMemberToStore) throws SQLException {
+
+ if (chatMemberToStore == null
+ || chatMemberToStore.getCallSignRaw() == null
+ || chatMemberToStore.getCallSignRaw().isBlank()) {
+ return false;
+ }
+
+ String updateWorkedAnySql =
+ "UPDATE ChatMember SET worked = 1, "
+ + "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
+ + "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
+
+ try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedAnySql)) {
+ String qra = chatMemberToStore.getQra();
+
+ preparedStatement.setString(1, qra);
+ preparedStatement.setString(2, qra);
+ preparedStatement.setString(3, qra);
+ preparedStatement.setString(4, qra);
+ preparedStatement.setLong(5, System.currentTimeMillis());
+ preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());
+
+ int affectedRows = preparedStatement.executeUpdate();
+ return affectedRows > 0;
+ }
+ }
+
+
/**
* Updates all not-QRV flags for a chatmember row. The method uses the normalized
* raw callsign and updates the timestamp so that automatic contest cleanup can
diff --git a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java
index 2bfa2ba..fd7feeb 100644
--- a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java
+++ b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java
@@ -119,6 +119,31 @@ public final class WorkedGrossFieldCache {
.contains(grossField);
}
+ /**
+ * Checks whether a locator gross field has already been worked on any band.
+ *
+ * The UI grid status and the "Only new grids" filter use this any-band logic.
+ * This avoids user errors caused by wrong active-band settings and fits single
+ * band contests such as NAC better.
+ *
+ * @param locatorOrGrossField six-character locator or four-character gross field
+ * @return true if the gross field exists in the cache on any band
+ */
+ public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
+ String grossField = extractGrossField(locatorOrGrossField);
+ if (grossField == null) {
+ return false;
+ }
+
+ for (Set workedGrossFields : workedGrossFieldsByBand.values()) {
+ if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java
index 85fb58b..02755bc 100644
--- a/src/main/java/kst4contest/view/Kst4ContestApplication.java
+++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java
@@ -67,12 +67,17 @@ import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService;
+import kst4contest.controller.WorkedGrossFieldCache;
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
// private static final Kst4ContestApplication dbcontroller = new DBController();
// Null means Auto: use the lowest session band, or category fallback.
+ // Enables optional color highlighting of the QRA/grid cell.
+// The status text itself remains visible independently of this flag.
+ private boolean gridSquareHighlightEnabled = false;
+
private PauseTransition userListRefreshCoalescer;
private String pendingUserListUpdateReason = "";
@@ -1260,6 +1265,75 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
});
+ /**
+ * Optionally highlights already worked grid squares in the QRA column.
+ *
+ * The default table design is preserved as much as possible:
+ *
+ * - If grid coloring is disabled, the cell uses the standard JavaFX style.
+ * - If the grid is not worked yet, the cell also uses the standard style.
+ * - Only already worked grids get a subtle background color.
+ * - Selected rows keep the normal table selection style.
+ *
+ *
+ * No font weight is changed here. The compact worked/grid status such as
+ * {@code xo} is emphasized only in the worked-any column.
+ */
+ qraCol.setCellFactory(column -> new TableCell() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+
+ if (empty || item == null) {
+ setText(null);
+ setTooltip(null);
+ setStyle("");
+ return;
+ }
+
+ ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
+ boolean gridWorked = member != null
+ && chatcontroller != null
+ && chatcontroller.isGridSquareWorkedAny(member);
+
+ String grossField = WorkedGrossFieldCache.extractGrossField(item);
+
+ setText(item);
+ setTooltip(new Tooltip(
+ "Grid status: "
+ + (grossField == null ? "unknown" : grossField)
+ + "\nGrid worked any: "
+ + (gridWorked ? "yes" : "no")
+ ));
+
+ /*
+ * Important:
+ * Do not style the cell unless the Grid color button is active AND the
+ * grid has already been worked. This keeps normal/new grids visually
+ * identical to the standard table design.
+ */
+ if (!gridSquareHighlightEnabled || !gridWorked) {
+ setStyle("");
+ return;
+ }
+
+ /*
+ * Preserve normal selection appearance. A selected row should look like a
+ * selected row, not like a custom-colored QRA cell.
+ */
+ if (isSelected() || (getTableRow() != null && getTableRow().isSelected())) {
+ setStyle("");
+ return;
+ }
+
+ /*
+ * Already worked grid:
+ * Use a subtle background derived from the current theme. No bold text.
+ */
+ setStyle("-fx-background-color: derive(-fx-control-inner-background, -18%);");
+ }
+ });
+
TableColumn qrBCol = new TableColumn("QRB");
qrBCol.setCellValueFactory(new Callback, ObservableValue>() {
@@ -1484,19 +1558,60 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked()) {
- wkd.setValue("X");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatWorkedAnyGridStatus(cellDataFeatures.getValue()));
}
});
wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(14));
+ /**
+ * Shows the compact worked/grid status and explains it by tooltip.
+ */
+ wkdAny_subcol.setCellFactory(column -> new TableCell() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+
+ if (empty) {
+ setText(null);
+ setTooltip(null);
+ setStyle("");
+ return;
+ }
+
+ ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
+
+ setText(item);
+ setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
+ setAlignment(Pos.CENTER);
+ setStyle("-fx-font-weight: bold;");
+ }
+ });
+
+ /**
+ * Shows the compact worked/grid status and explains it by tooltip.
+ */
+ wkdAny_subcol.setCellFactory(column -> new TableCell() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+
+ if (empty) {
+ setText(null);
+ setTooltip(null);
+ setStyle("");
+ return;
+ }
+
+ ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
+
+ setText(item);
+ setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
+ setAlignment(Pos.CENTER);
+ setStyle("-fx-font-weight: bold;");
+ }
+ });
+
+
TableColumn vhfCol_subcol = new TableColumn("144");
vhfCol_subcol
.setCellValueFactory(new Callback, ObservableValue>() {
@@ -5487,29 +5602,79 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMessage sendMe = new ChatMessage();
- /**
- * testing bugfix
+ /*
+ * Resolve the selected station safely.
+ *
+ * Normal case:
+ * - selectedCallSignInfoStageChatMember is set by clicking/selecting a station.
+ *
+ * Fallbacks:
+ * - current table selection
+ * - ScoreService selected member
+ *
+ * If no station is selected at all, the message is sent to the main category.
*/
+ ChatMember effectiveSelectedMember = selectedCallSignInfoStageChatMember;
- ChatCategory sendMeInThisCat;
- String categoryNumber = selectedCallSignInfoStageChatMember.getChatCategory().getCategoryNumber() +"";
-
- if (categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) {
- sendMeInThisCat = chatcontroller.getChatCategoryMain();
-
- } else if (categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) {
- sendMeInThisCat = chatcontroller.getChatCategorySecondChat();
-
- } else {
- sendMeInThisCat = chatcontroller.getChatCategoryMain(); //Chatcategory default decision
+ if (effectiveSelectedMember == null
+ && tbl_chatMember != null
+ && tbl_chatMember.getSelectionModel() != null) {
+ effectiveSelectedMember = tbl_chatMember.getSelectionModel().getSelectedItem();
}
- System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is " + sendMeInThisCat + " // selected member: " + selectedCallSignInfoStageChatMember.getChatCategory() + " evt " + event.isConsumed() );
- /**
- * end testing bugfix
- */
+ if (effectiveSelectedMember == null
+ && chatcontroller != null
+ && chatcontroller.getScoreService() != null) {
+ effectiveSelectedMember = chatcontroller.getScoreService().getSelectedChatMember();
+ }
- sendMe.setChatCategory(sendMeInThisCat); //new in 1.26, answer in channel of the selected member
+ /*
+ * Default decision:
+ * If no station is selected, use the main category.
+ */
+ ChatCategory sendMeInThisCat = chatcontroller.getChatCategoryMain();
+
+ /*
+ * If a station is selected and its category matches one of our active chat
+ * categories, send in that category. This keeps the old behaviour, but avoids
+ * NullPointerExceptions when no station has been selected yet.
+ */
+ if (effectiveSelectedMember != null && effectiveSelectedMember.getChatCategory() != null) {
+
+ String categoryNumber = effectiveSelectedMember.getChatCategory().getCategoryNumber() + "";
+
+ if (chatcontroller.getChatCategoryMain() != null
+ && categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) {
+
+ sendMeInThisCat = chatcontroller.getChatCategoryMain();
+
+ } else if (chatcontroller.getChatCategorySecondChat() != null
+ && categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) {
+
+ sendMeInThisCat = chatcontroller.getChatCategorySecondChat();
+
+ } else {
+ sendMeInThisCat = chatcontroller.getChatCategoryMain(); // Chatcategory default decision
+ }
+ }
+
+ String selectedMemberDebugText;
+ if (effectiveSelectedMember == null) {
+ selectedMemberDebugText = "none, using main category";
+ } else {
+ selectedMemberDebugText = effectiveSelectedMember.getCallSignRaw()
+ + " / "
+ + effectiveSelectedMember.getChatCategory();
+ }
+
+ System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is "
+ + sendMeInThisCat
+ + " // selected member: "
+ + selectedMemberDebugText
+ + " evt "
+ + event.isConsumed());
+
+ sendMe.setChatCategory(sendMeInThisCat); // new in 1.26, answer in channel of the selected member if available
sendMe.setMessageText(txt_chatMessageUserInput.getText());
// If operator sends "/cq CALL ..." => arm pending ping metrics for reply-time / no-reply tracking
@@ -5518,11 +5683,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
sendMe.setMessageDirectedToServer(false);
- chatcontroller.getMessageTXBus().add(sendMe); //move the message to the tx queue
+ chatcontroller.getMessageTXBus().add(sendMe); // move the message to the tx queue
txt_chatMessageUserInput.clear();
-
-
}
});
@@ -6120,13 +6283,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
- ToggleButton btnTglNewLocator = new ToggleButton("New locator");
+ ToggleButton btnTglNewLocator = new ToggleButton("Only new grids");
Predicate newLocatorPredicate = new Predicate() {
@Override
public boolean test(ChatMember chatMember) {
- return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember);
+ return chatcontroller.isNewGridSquare(chatMember);
}
};
+
btnTglNewLocator.setOnAction(new EventHandler() {
@Override
public void handle(ActionEvent actionEvent) {
@@ -6137,7 +6301,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
}
});
- btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose gross locator is still new on at least one active own band"));
+ btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose 4-character grid square has not been worked on any band yet"));
+
+
+ ToggleButton btnTglGridColor = new ToggleButton("Grid color");
+
+ /**
+ * Enables optional QRA-cell coloring for grid status.
+ *
+ * This does not filter the list. It only colors the locator field:
+ * worked grid = darker, new grid = brighter.
+ */
+ btnTglGridColor.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ gridSquareHighlightEnabled = btnTglGridColor.isSelected();
+ tbl_chatMember.refresh();
+ }
+ });
+
+ btnTglGridColor.setTooltip(new Tooltip("Color the QRA field by grid status without filtering the station list"));
ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB");
Predicate reachableTropoPredicate = new Predicate() {
@@ -6807,6 +6990,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
btnTglNewLocator,
+ btnTglGridColor,
btnTglReachableTropo,
btnTglNewBands,
btnTglAsNext5Min
@@ -9426,6 +9610,75 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return sb.toString();
}
+
+ /**
+ * Formats the global worked/grid status for the worked-any column.
+ *
+ * Status characters follow the user feedback:
+ *
+ * - empty = call not worked, grid not worked
+ * - x = call worked
+ * - o = grid worked, call not worked
+ * - xo = call and grid worked
+ *
+ *
+ * The call status is based on {@link ChatMember#isWorked()}, i.e. worked-any.
+ * The grid status is based on the four-character grid square worked on any band.
+ *
+ * @param member station row
+ * @return compact status text
+ */
+ private String formatWorkedAnyGridStatus(ChatMember member) {
+ if (member == null) {
+ return "";
+ }
+
+ boolean callWorked = member.isWorked();
+ boolean gridWorked = chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member);
+
+ if (callWorked && gridWorked) {
+ return "xo";
+ }
+
+ if (callWorked) {
+ return "x";
+ }
+
+ if (gridWorked) {
+ return "o";
+ }
+
+ return "";
+ }
+
+ /**
+ * Builds a tooltip for the worked-any/grid-status cell.
+ *
+ * @param member station row
+ * @return tooltip text
+ */
+ private String buildWorkedAnyGridStatusTooltip(ChatMember member) {
+ if (member == null) {
+ return "empty = call not worked, grid not worked\n"
+ + "x = call worked\n"
+ + "o = grid worked\n"
+ + "xo = call and grid worked";
+ }
+
+ String grossField = WorkedGrossFieldCache.extractGrossField(member.getQra());
+ String gridText = grossField == null ? "unknown grid" : "grid " + grossField;
+
+ return "Worked status:\n"
+ + "empty = call not worked, grid not worked\n"
+ + "x = call worked\n"
+ + "o = grid worked\n"
+ + "xo = call and grid worked\n\n"
+ + "This station:\n"
+ + "Call worked: " + (member.isWorked() ? "yes" : "no") + "\n"
+ + "Grid worked: " + (chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) ? "yes" : "no")
+ + " (" + gridText + ")";
+ }
+
// /**
// *
// * resets the style of the not selected direction buttons
@@ -9706,6 +9959,72 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// }
+ /**
+ * Returns the best currently selected ChatMember for actions that depend on a
+ * selected station.
+ *
+ * The normal source is selectedCallSignInfoStageChatMember. As a fallback,
+ * the current table selection and the ScoreService selection are checked. If
+ * nothing is selected yet, null is returned. This is valid and must be handled
+ * by the caller.
+ *
+ * @return selected ChatMember or null if no station is selected
+ */
+ private ChatMember getEffectiveSelectedChatMember() {
+ if (selectedCallSignInfoStageChatMember != null) {
+ return selectedCallSignInfoStageChatMember;
+ }
+
+ if (tbl_chatMember != null
+ && tbl_chatMember.getSelectionModel() != null
+ && tbl_chatMember.getSelectionModel().getSelectedItem() != null) {
+ return tbl_chatMember.getSelectionModel().getSelectedItem();
+ }
+
+ if (chatcontroller != null
+ && chatcontroller.getScoreService() != null
+ && chatcontroller.getScoreService().getSelectedChatMember() != null) {
+ return chatcontroller.getScoreService().getSelectedChatMember();
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolves the chat category for an outgoing operator message.
+ *
+ * If a station is selected, the message is sent in the category of that
+ * station. If no station is selected yet, the message is sent in the main chat
+ * category. This prevents NullPointerExceptions when the operator writes to the
+ * chat immediately after joining.
+ *
+ * @param selectedMember selected station, may be null
+ * @return category for the outgoing message, normally main or second category
+ */
+ private ChatCategory resolveOutgoingChatCategory(ChatMember selectedMember) {
+ ChatCategory mainCategory = chatcontroller.getChatCategoryMain();
+ ChatCategory secondCategory = chatcontroller.getChatCategorySecondChat();
+
+ if (selectedMember == null || selectedMember.getChatCategory() == null) {
+ return mainCategory;
+ }
+
+ String categoryNumber = selectedMember.getChatCategory().getCategoryNumber() + "";
+
+ if (mainCategory != null
+ && categoryNumber.equals(mainCategory.getCategoryNumber() + "")) {
+ return mainCategory;
+ }
+
+ if (secondCategory != null
+ && categoryNumber.equals(secondCategory.getCategoryNumber() + "")) {
+ return secondCategory;
+ }
+
+ return mainCategory;
+ }
+
+
/**
* helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string
* @param raw