Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A JavaFX 21 cross-platform desktop application for learning student names throug
- **Model**: Java 21 records for data (Student, GameSession, etc.)
- **Service**: Business logic (ConfigService, RosterService, ImageService, etc.)
- **Controller**: FXML controllers for views
- **Util**: Animation factory, helpers
- **Util**: Animation factory, AppLogger, helpers

## Key Features
- Load student photos from configurable directory
Expand All @@ -31,6 +31,24 @@ A JavaFX 21 cross-platform desktop application for learning student names throug
- All UI updates on JavaFX Application Thread (Platform.runLater)
- Store user data in ~/.namegame/ (installed) or ./data/ (portable)

## Logging
- **Never** use `System.err.println()` or `System.out.println()` directly in service or controller code.
On Windows jpackage (MSI/EXE) installs the app runs as a pure GUI process with no console
window, so all console output is silently discarded.
- Use `AppLogger.log(String)` / `AppLogger.log(String, Throwable)` from
`com.example.namegame.util.AppLogger` in every layer (services, controllers, utilities).
- `AppLogger.init(Path)` is called once at startup (`NameGameApplication.main()`) before any
service singleton is created. It directs output to both `stderr` **and** the persistent log
file at `~/.namegame/namegame.log` (or `%TEMP%\namegame.log` as a fallback).
- Fatal startup exceptions are caught in `NameGameApplication.start()` and shown to the user
via a JavaFX error dialog (with an expandable stack-trace area) rather than dying silently.

## Error Handling
- Wrap `Application.start()` in a broad try/catch and call `showFatalErrorDialog()` so the
user always gets visible feedback when the app cannot start.
- Non-fatal errors in services (e.g. missing sound files, I/O on statistics) should be logged
with `AppLogger.log()` and allow the app to continue running in a degraded state.

## Build Commands
```bash
mvn clean package # Full build
Expand Down
55 changes: 13 additions & 42 deletions src/main/java/com/example/namegame/NameGameApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.example.namegame.service.ImageService;
import com.example.namegame.service.KeyboardShortcutService;
import com.example.namegame.service.SoundService;
import com.example.namegame.util.AppLogger;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
Expand All @@ -17,14 +18,10 @@
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* Main application entry point for Student Name Game.
Expand All @@ -36,9 +33,6 @@ public class NameGameApplication extends Application {
private static final int DEFAULT_WIDTH = 900;
private static final int DEFAULT_HEIGHT = 700;

/** Log file written on every launch; visible even without a console window. */
private static Path logFile;

// -----------------------------------------------------------------------
// Logging helpers
// -----------------------------------------------------------------------
Expand All @@ -60,31 +54,6 @@ private static Path resolveLogFile() {
return null; // logging completely disabled
}

static void log(String message) {
log(message, null);
}

static void log(String message, Throwable t) {
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
StringBuilder sb = new StringBuilder();
sb.append('[').append(timestamp).append("] ").append(message)
.append(System.lineSeparator());
if (t != null) {
StringWriter sw = new StringWriter();
t.printStackTrace(new PrintWriter(sw));
sb.append(sw).append(System.lineSeparator());
}
String entry = sb.toString();
System.err.print(entry);
if (logFile != null) {
try {
Files.writeString(logFile, entry,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException ignored) { /* nothing left to do */ }
}
}

// -----------------------------------------------------------------------
// Fatal-error dialog (replaces a missing stderr console in GUI installs)
// -----------------------------------------------------------------------
Expand All @@ -97,6 +66,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle(APP_TITLE + " – Fatal Error");
alert.setHeaderText("The application failed to start.");
Path logFile = AppLogger.getLogFile();
String logHint = (logFile != null)
? "\n\nFull details: " + logFile
: "";
Expand All @@ -116,7 +86,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) {

alert.showAndWait();
} catch (Exception dialogEx) {
log("Failed to show fatal-error dialog", dialogEx);
AppLogger.log("Failed to show fatal-error dialog", dialogEx);
} finally {
Platform.exit();
}
Expand All @@ -129,7 +99,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) {
@Override
public void start(Stage primaryStage) {
try {
log("start() called");
AppLogger.log("start() called");
ConfigService config = ConfigService.getInstance();

// Initialize SoundService early to load sounds
Expand All @@ -141,7 +111,7 @@ public void start(Stage primaryStage) {
loadImagesAndShowDashboard(primaryStage);
}
} catch (Exception e) {
log("Fatal error during startup", e);
AppLogger.log("Fatal error during startup", e);
showFatalErrorDialog(primaryStage, e);
}
}
Expand All @@ -160,7 +130,7 @@ private void showWelcomeDialog(Stage primaryStage) throws Exception {
try {
loadImagesAndShowDashboard(primaryStage);
} catch (Exception e) {
log("Error loading dashboard after welcome dialog", e);
AppLogger.log("Error loading dashboard after welcome dialog", e);
Platform.runLater(() -> showFatalErrorDialog(primaryStage, e));
}
});
Expand Down Expand Up @@ -211,21 +181,22 @@ public void stop() {
}

public static void main(String[] args) {
// Resolve log file before anything else so every startup attempt is recorded.
logFile = resolveLogFile();
log("=== Student Name Game starting (version " + APP_VERSION + ") ===");
log("java.version=" + System.getProperty("java.version")
// Initialise the log file before anything else so every startup attempt
// is recorded — even if it happens before the JavaFX toolkit starts.
AppLogger.init(resolveLogFile());
AppLogger.log("=== Student Name Game starting (version " + APP_VERSION + ") ===");
AppLogger.log("java.version=" + System.getProperty("java.version")
+ " os.name=" + System.getProperty("os.name")
+ " user.home=" + System.getProperty("user.home"));

// Catch any thread that dies without an explicit handler (non-FX threads).
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) ->
log("Uncaught exception on thread \"" + thread.getName() + "\"", throwable));
AppLogger.log("Uncaught exception on thread \"" + thread.getName() + "\"", throwable));

try {
launch(args);
} catch (Exception e) {
log("Fatal error in launch()", e);
AppLogger.log("Fatal error in launch()", e);
}
}
}
13 changes: 7 additions & 6 deletions src/main/java/com/example/namegame/service/ImageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.example.namegame.model.ScoredMatch;
import com.example.namegame.model.Student;
import com.example.namegame.model.UnmatchedImage;
import com.example.namegame.util.AppLogger;
import javafx.application.Platform;

import java.io.IOException;
Expand Down Expand Up @@ -51,13 +52,13 @@ public boolean loadImages() {

Path directory = ConfigService.getInstance().getImagesDirectory();
if (directory == null || !Files.isDirectory(directory)) {
System.err.println("Invalid images directory");
AppLogger.log("Invalid images directory: " + directory);
return false;
}

// Load roster first
if (!RosterService.getInstance().loadRoster(directory)) {
System.err.println("Failed to load roster");
AppLogger.log("Failed to load roster from: " + directory);
return false;
}

Expand All @@ -69,12 +70,12 @@ public boolean loadImages() {
.filter(this::isImageFile)
.forEach(path -> processImageFile(path, squashedToOriginal, manualMappings));
} catch (IOException e) {
System.err.println("Failed to list directory: " + e.getMessage());
AppLogger.log("Failed to list directory: " + directory, e);
return false;
}

System.out.println("Loaded " + students.size() + " students, " +
unmatchedImages.size() + " unmatched images");
AppLogger.log("Loaded " + students.size() + " students, " +
unmatchedImages.size() + " unmatched images");

return !students.isEmpty();
}
Expand Down Expand Up @@ -201,7 +202,7 @@ public void startWatching(Consumer<Void> callback) {
watchThread = Thread.startVirtualThread(this::watchLoop);

} catch (IOException e) {
System.err.println("Failed to start file watcher: " + e.getMessage());
AppLogger.log("Failed to start file watcher", e);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.namegame.service;

import com.example.namegame.util.AppLogger;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
Expand Down Expand Up @@ -46,7 +47,7 @@ private void load() {
mappings = new HashMap<>();
}
} catch (IOException e) {
System.err.println("Failed to load mappings: " + e.getMessage());
AppLogger.log("Failed to load mappings: " + mappingsPath, e);
mappings = new HashMap<>();
}
} else {
Expand All @@ -59,7 +60,7 @@ private void save() {
String json = gson.toJson(mappings);
Files.writeString(mappingsPath, json);
} catch (IOException e) {
System.err.println("Failed to save mappings: " + e.getMessage());
AppLogger.log("Failed to save mappings: " + mappingsPath, e);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.namegame.service;

import com.example.namegame.util.AppLogger;
import org.apache.poi.ss.usermodel.*;

import java.io.*;
Expand Down Expand Up @@ -39,7 +40,7 @@ public boolean loadRoster(Path directory) {
// Find roster file
Path rosterFile = findRosterFile(directory);
if (rosterFile == null) {
System.err.println("No roster file (roster.xls or roster.xlsx) found in: " + directory);
AppLogger.log("No roster file (roster.xls or roster.xlsx) found in: " + directory);
return false;
}

Expand All @@ -53,7 +54,7 @@ public boolean loadRoster(Path directory) {
int nameColumnIndex = findNameColumn(headerRow);

if (nameColumnIndex < 0) {
System.err.println("'Name' column not found in roster");
AppLogger.log("'Name' column not found in roster: " + rosterFile);
return false;
}

Expand All @@ -74,11 +75,11 @@ public boolean loadRoster(Path directory) {
}
}

System.out.println("Loaded " + rosterNames.size() + " names from roster");
AppLogger.log("Loaded " + rosterNames.size() + " names from roster: " + rosterFile);
return !rosterNames.isEmpty();

} catch (IOException e) {
System.err.println("Failed to read roster: " + e.getMessage());
AppLogger.log("Failed to read roster: " + rosterFile, e);
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.namegame.service;

import com.example.namegame.util.AppLogger;
import javafx.scene.media.AudioClip;

import java.net.URL;
Expand Down Expand Up @@ -41,11 +42,11 @@ private AudioClip loadSound(String resourcePath) {
if (url != null) {
return new AudioClip(url.toExternalForm());
} else {
System.err.println("Sound not found: " + resourcePath);
AppLogger.log("Sound not found: " + resourcePath);
return null;
}
} catch (Exception e) {
System.err.println("Failed to load sound " + resourcePath + ": " + e.getMessage());
AppLogger.log("Failed to load sound " + resourcePath, e);
return null;
}
}
Expand All @@ -71,7 +72,7 @@ private void playSound(AudioClip clip) {
try {
clip.play();
} catch (Exception e) {
System.err.println("Error playing sound: " + e.getMessage());
AppLogger.log("Error playing sound", e);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.example.namegame.model.GameStatistics;
import com.example.namegame.model.Student;
import com.example.namegame.util.AppLogger;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

Expand Down Expand Up @@ -43,7 +44,7 @@ private void load() {
statistics = new GameStatistics();
}
} catch (IOException e) {
System.err.println("Failed to load statistics: " + e.getMessage());
AppLogger.log("Failed to load statistics: " + statisticsPath, e);
statistics = new GameStatistics();
}
} else {
Expand All @@ -56,7 +57,7 @@ private void save() {
String json = gson.toJson(statistics);
Files.writeString(statisticsPath, json);
} catch (IOException e) {
System.err.println("Failed to save statistics: " + e.getMessage());
AppLogger.log("Failed to save statistics: " + statisticsPath, e);
}
}

Expand Down
Loading
Loading