001package com.studentgui.apppages; 002 003import java.awt.BorderLayout; 004import java.awt.Dimension; 005import java.awt.Font; 006import java.awt.GridBagConstraints; 007import java.awt.GridBagLayout; 008import java.awt.Insets; 009import java.awt.event.ActionEvent; 010import java.awt.event.KeyEvent; 011import java.sql.SQLException; 012import java.time.LocalDate; 013 014import javax.swing.JButton; 015import javax.swing.JLabel; 016import javax.swing.JOptionPane; 017import javax.swing.JPanel; 018import javax.swing.JScrollPane; 019import javax.swing.JTextField; 020import javax.swing.SwingUtilities; 021 022import org.slf4j.Logger; 023import org.slf4j.LoggerFactory; 024 025/** 026 * Touch-typing and keyboarding skills assessment page. 027 * 028 * <p>Unlike other assessment pages that use phase-score grids, this page captures 029 * structured performance metrics for keyboarding practice sessions:</p> 030 * 031 * <ul> 032 * <li><b>Program:</b> Name of the typing curriculum or software (e.g., TypingClub, KeyBlaze, Braille2000)</li> 033 * <li><b>Topic:</b> Specific lesson, module, or exercise completed (e.g., "Home Row Mastery", "Lesson 12")</li> 034 * <li><b>Speed (WPM):</b> Words per minute achieved during the timed exercise</li> 035 * <li><b>Accuracy (%):</b> Percentage of characters typed correctly</li> 036 * </ul> 037 * 038 * <p><b>Data Persistence:</b></p> 039 * <ul> 040 * <li>Values persisted via {@link com.studentgui.apphelpers.Database#insertKeyboardingResult} to the {@code KeyboardingResult} table</li> 041 * <li>JSON export: {@code StudentDataFiles/<student>/Sessions/Keyboarding/Keyboarding-<sessionId>-<timestamp>.json}</li> 042 * <li>Metadata-only reports (no plots): Markdown and HTML files in {@code reports/} with session details</li> 043 * </ul> 044 * 045 * <p><b>Validation and Error Handling:</b></p> 046 * <ul> 047 * <li>Speed and Accuracy fields must contain whole numbers (non-negative integers)</li> 048 * <li>Empty speed/accuracy fields default to 0 for leniency</li> 049 * <li>Invalid input triggers error dialogs and field focus for correction</li> 050 * </ul> 051 * 052 * <p>The shared {@link JLineGraph} component is present for UI consistency but is not populated 053 * with keyboarding data (keyboarding does not use assessment parts). Implements 054 * {@link com.studentgui.app.DateChangeListener} and {@link com.studentgui.app.StudentChangeListener} 055 * for title updates when global selections change.</p> 056 * 057 * @see com.studentgui.apphelpers.Database#insertKeyboardingResult 058 * @see com.studentgui.apphelpers.dto.KeyboardingPayload 059 */ 060public class Keyboarding extends JPanel implements com.studentgui.app.DateChangeListener, com.studentgui.app.StudentChangeListener { 061 private static final Logger LOG = LoggerFactory.getLogger(Keyboarding.class); 062 /** Text field for the program or curriculum name. */ 063 private final JTextField programField, topicField, speedField, accuracyField; 064 065 /** Shared graph component (present but not used for keyboarding plotting). */ 066 private final JLineGraph lineGraph; 067 068 /** Selected student's display name for saves/refreshes (may be null). */ 069 private String studentNameParam; 070 /** Page header label. */ 071 private JLabel titleLabel; 072 /** Base title text for the Keyboarding page; date suffix appended in UI. */ 073 private final String baseTitle = "Keyboarding Skills"; 074 075 /** Session date associated with persisted keyboarding results. */ 076 private LocalDate dateParam; 077 078 /** 079 * Construct the Keyboarding page for a specific student and session date. 080 * 081 * @param studentName selected student's display name (may be null) 082 * @param date session date used for persisted results 083 * @param lineGraph shared graph component (unused for keyboarding results) 084 */ 085 public Keyboarding(String studentName, LocalDate date, JLineGraph lineGraph) { 086 this.studentNameParam = (studentName == null || studentName.trim().isEmpty()) ? com.studentgui.apphelpers.Helpers.defaultStudent() : studentName; 087 this.dateParam = date; 088 this.lineGraph = lineGraph; 089 setLayout(new BorderLayout()); 090 091 JPanel p = new JPanel(new GridBagLayout()); 092 JPanel view = new JPanel(new BorderLayout()); 093 view.add(p, BorderLayout.NORTH); 094 view.setBorder(javax.swing.BorderFactory.createEmptyBorder(20,20,20,20)); 095 JScrollPane scroll = new JScrollPane(view); 096 scroll.getAccessibleContext().setAccessibleName("Keyboarding data entry scroll pane"); 097 p.setBorder(javax.swing.BorderFactory.createEmptyBorder(20,20,20,20)); 098 GridBagConstraints gbc = new GridBagConstraints(); gbc.insets=new Insets(2,2,2,2); gbc.fill = GridBagConstraints.HORIZONTAL; gbc.anchor = GridBagConstraints.NORTHWEST; 099 this.titleLabel = new JLabel(baseTitle, JLabel.LEFT); 100 this.titleLabel.setFont(this.titleLabel.getFont().deriveFont(Font.BOLD,28f)); 101 this.titleLabel.getAccessibleContext().setAccessibleName("Keyboarding Skills Title"); 102 gbc.gridx=0; gbc.gridy=0; gbc.gridwidth=2; p.add(this.titleLabel, gbc); 103 104 gbc.gridwidth=1; 105 // Normalize label width to the PhaseScoreField global width so inputs align 106 int globalLabel = com.studentgui.uicomp.PhaseScoreField.getGlobalLabelWidth(); 107 gbc.gridy=1; gbc.gridx=0; JLabel programLabel = new JLabel("Program:"); programLabel.setPreferredSize(new Dimension(globalLabel, programLabel.getPreferredSize().height)); p.add(programLabel, gbc); gbc.gridx=1; programField = new JTextField(); programField.setPreferredSize(new Dimension(300,24)); programField.setToolTipText("Name of the program or curriculum"); programField.getAccessibleContext().setAccessibleName("Program"); p.add(programField, gbc); programLabel.setLabelFor(programField); 108 gbc.gridy=2; gbc.gridx=0; JLabel topicLabel = new JLabel("Topic:"); topicLabel.setPreferredSize(new Dimension(globalLabel, topicLabel.getPreferredSize().height)); p.add(topicLabel, gbc); gbc.gridx=1; topicField = new JTextField(); topicField.setPreferredSize(new Dimension(300,24)); topicField.setToolTipText("Topic or lesson name"); topicField.getAccessibleContext().setAccessibleName("Topic"); p.add(topicField, gbc); topicLabel.setLabelFor(topicField); 109 gbc.gridy=3; gbc.gridx=0; JLabel speedLabel = new JLabel("Speed (WPM):"); speedLabel.setPreferredSize(new Dimension(globalLabel, speedLabel.getPreferredSize().height)); p.add(speedLabel, gbc); gbc.gridx=1; speedField = new JTextField("0"); speedField.setPreferredSize(new Dimension(100,24)); speedField.setToolTipText("Words per minute"); speedField.getAccessibleContext().setAccessibleName("Speed (WPM)"); p.add(speedField, gbc); speedLabel.setLabelFor(speedField); 110 gbc.gridy=4; gbc.gridx=0; JLabel accuracyLabel = new JLabel("Accuracy (%):"); accuracyLabel.setPreferredSize(new Dimension(globalLabel, accuracyLabel.getPreferredSize().height)); p.add(accuracyLabel, gbc); gbc.gridx=1; accuracyField = new JTextField("0"); accuracyField.setPreferredSize(new Dimension(100,24)); accuracyField.setToolTipText("Accuracy percentage"); accuracyField.getAccessibleContext().setAccessibleName("Accuracy (%)"); p.add(accuracyField, gbc); accuracyLabel.setLabelFor(accuracyField); 111 112 gbc.gridy=5; gbc.gridx=0; gbc.gridwidth=GridBagConstraints.REMAINDER; 113 JButton submit = new JButton("Submit Data"); 114 submit.setPreferredSize(new java.awt.Dimension(0, 32)); 115 submit.addActionListener((ActionEvent e)-> { submitData(); refreshGraph(); }); 116 submit.setToolTipText("Save keyboarding result for selected student"); 117 submit.setMnemonic(KeyEvent.VK_S); 118 submit.getAccessibleContext().setAccessibleName("Submit Keyboarding Data"); 119 p.add(submit, gbc); 120 gbc.gridwidth = 1; 121 // Removed separate Refresh Graph button; Submit Data now triggers refreshGraph 122 123 add(scroll, BorderLayout.CENTER); 124 add(this.lineGraph, BorderLayout.SOUTH); 125 126 SwingUtilities.invokeLater(()->{ p.setPreferredSize(p.getPreferredSize()); updateTitleDate(); revalidate(); }); 127 128 com.studentgui.apphelpers.Helpers.createFolderHierarchy(); 129 initDatabase(); 130 refreshGraph(); 131 } 132 133 /** 134 * Ensure the keyboarding progress type exists in the canonical schema. 135 */ 136 private void initDatabase() { 137 try { 138 com.studentgui.apphelpers.Database.getOrCreateProgressType("Keyboarding"); 139 } catch (SQLException ex) { 140 LOG.error("Error ensuring Keyboarding progress type", ex); 141 } 142 } 143 144 /** 145 * Validate keyboarding inputs (speed and accuracy as integers) and 146 * persist a keyboarding result record for the selected student. 147 */ 148 private void submitData() { 149 if (this.studentNameParam == null || this.studentNameParam.trim().isEmpty()) { 150 JOptionPane.showMessageDialog(this, "Please select a student before saving keyboarding data.", "Missing student", JOptionPane.WARNING_MESSAGE); 151 return; 152 } 153 154 try { 155 int studentId = com.studentgui.apphelpers.Database.getOrCreateStudent(this.studentNameParam); 156 int ptId = com.studentgui.apphelpers.Database.getOrCreateProgressType("Keyboarding"); 157 int sessionId = com.studentgui.apphelpers.Database.createProgressSession(studentId, ptId, this.dateParam); 158 159 String program = programField.getText().trim(); 160 String topic = topicField.getText().trim(); 161 int speed; 162 int accuracy; 163 try { 164 String sp = speedField.getText().trim(); speed = sp.isEmpty() ? 0 : Integer.parseInt(sp); 165 } catch (NumberFormatException nfe) { 166 JOptionPane.showMessageDialog(this, "Please enter a whole number for Speed (WPM)", "Invalid input", JOptionPane.ERROR_MESSAGE); 167 speedField.requestFocusInWindow(); 168 return; 169 } 170 try { 171 String ac = accuracyField.getText().trim(); accuracy = ac.isEmpty() ? 0 : Integer.parseInt(ac); 172 } catch (NumberFormatException nfe) { 173 JOptionPane.showMessageDialog(this, "Please enter a whole number for Accuracy (%)", "Invalid input", JOptionPane.ERROR_MESSAGE); 174 accuracyField.requestFocusInWindow(); 175 return; 176 } 177 178 com.studentgui.apphelpers.Database.insertKeyboardingResult(sessionId, program, topic, speed, accuracy); 179 LOG.info("Keyboarding data saved for {}", this.studentNameParam); 180 com.studentgui.apphelpers.UiNotifier.show("Keyboarding data saved."); 181 com.studentgui.apphelpers.dto.KeyboardingPayload payload = new com.studentgui.apphelpers.dto.KeyboardingPayload(sessionId, program, topic, speed, accuracy); 182 java.nio.file.Path jsonOut = com.studentgui.apphelpers.SessionJsonWriter.writeSessionJson(this.studentNameParam, "Keyboarding", payload, sessionId); 183 if (jsonOut == null) { 184 LOG.warn("Unable to save Keyboarding session JSON for sessionId={}", sessionId); 185 } 186 try { 187 java.nio.file.Path plotsOut = com.studentgui.apphelpers.Helpers.studentPlotsDir(this.studentNameParam); 188 java.nio.file.Path reportsOut = com.studentgui.apphelpers.Helpers.studentReportsDir(this.studentNameParam); 189 java.nio.file.Files.createDirectories(plotsOut); 190 java.nio.file.Files.createDirectories(reportsOut); 191 java.time.format.DateTimeFormatter df = java.time.format.DateTimeFormatter.ISO_DATE; 192 String dateStr = this.dateParam != null ? this.dateParam.format(df) : java.time.LocalDate.now().toString(); 193 String baseName = "Keyboarding-" + sessionId + "-" + dateStr; 194 195 // Keyboarding doesn't have grouped codes; produce a small HTML/MD with metadata 196 StringBuilder md = new StringBuilder(); 197 md.append("# ").append(this.studentNameParam == null ? "Unknown Student" : this.studentNameParam).append(" - ").append(dateStr).append("\n\n"); 198 md.append("**Program:** ").append(program == null || program.isEmpty() ? "(none)" : program).append(" \n\n"); 199 md.append("**Topic:** ").append(topic == null || topic.isEmpty() ? "(none)" : topic).append(" \n\n"); 200 md.append("**Speed (WPM):** ").append(String.valueOf(speed)).append(" \n\n"); 201 md.append("**Accuracy (%):** ").append(String.valueOf(accuracy)).append(" \n\n"); 202 java.nio.file.Path mdFile = reportsOut.resolve(baseName + ".md"); 203 java.nio.file.Files.writeString(mdFile, md.toString(), java.nio.charset.StandardCharsets.UTF_8); 204 205 try { 206 StringBuilder html = new StringBuilder(); 207 html.append("<!doctype html><html><head><meta charset=\"utf-8\"><title>"); 208 html.append(this.studentNameParam == null ? "Student Report" : this.studentNameParam).append(" - ").append(dateStr).append("</title>"); 209 html.append("<style>body{font-family:sans-serif;margin:20px;} .meta{margin-bottom:12px;} .swatch{width:18px;height:12px;border:1px solid #333;display:inline-block;vertical-align:middle;margin-right:8px;}</style>"); 210 html.append("</head><body>"); 211 html.append("<h1>").append(this.studentNameParam == null ? "Unknown Student" : this.studentNameParam).append(" - ").append(dateStr).append("</h1>"); 212 html.append("<div class=\"meta\">\n"); 213 html.append("<p><strong>Program:</strong> ").append(program == null || program.isEmpty() ? "(none)" : program).append("</p>"); 214 html.append("<p><strong>Topic:</strong> ").append(topic == null || topic.isEmpty() ? "(none)" : topic).append("</p>"); 215 html.append("<p><strong>Speed (WPM):</strong> ").append(String.valueOf(speed)).append("</p>"); 216 html.append("<p><strong>Accuracy (%):</strong> ").append(String.valueOf(accuracy)).append("</p>"); 217 html.append("</div>"); 218 html.append("</body></html>"); 219 java.nio.file.Path htmlFile = reportsOut.resolve(baseName + ".html"); 220 java.nio.file.Files.writeString(htmlFile, html.toString(), java.nio.charset.StandardCharsets.UTF_8); 221 LOG.info("Wrote Keyboarding session report {}", htmlFile); 222 } catch (java.io.IOException ioex) { 223 LOG.warn("Unable to write Keyboarding HTML report: {}", ioex.toString()); 224 } 225 } catch (java.io.IOException ioe) { 226 LOG.warn("Unable to save Keyboarding report: {}", ioe.toString()); 227 } 228 } catch (SQLException ex) { 229 LOG.error("DB error saving keyboarding data", ex); 230 JOptionPane.showMessageDialog(this, "Database error saving keyboarding data: " + ex.getMessage(), "Database error", JOptionPane.ERROR_MESSAGE); 231 } 232 } 233 234 /** 235 * Refresh the keyboarding visualization. Currently keyboarding results are 236 * stored in a separate table and this method logs the request. 237 */ 238 private void refreshGraph() { 239 LOG.info("Keyboarding refresh requested for {}", studentNameParam); 240 } 241 242 @Override 243 /** 244 * Update the displayed date for the Keyboarding page and refresh content. 245 * 246 * Stores the provided `dateParam` and schedules a refresh on the Swing EDT 247 * so the UI (graph and title) reflects the new date. 248 * 249 * @param newDate the date to display (may be null to use current date) 250 */ 251 252 public void dateChanged(LocalDate newDate) { 253 this.dateParam = newDate; 254 SwingUtilities.invokeLater(() -> { 255 refreshGraph(); 256 updateTitleDate(); 257 }); 258 } 259 260 @Override 261 /** 262 * Update the selected student for the Keyboarding page and refresh content. 263 * 264 * Sets `studentNameParam` and schedules a UI update on the Swing EDT to 265 * reload page data and update the title. 266 * 267 * @param newStudent student identifier (name or id) to display; may be null 268 */ 269 270 public void studentChanged(String newStudent) { 271 this.studentNameParam = newStudent; 272 SwingUtilities.invokeLater(() -> { 273 refreshGraph(); 274 updateTitleDate(); 275 }); 276 } 277 278 private void updateTitleDate() { 279 try { 280 String dateStr = this.dateParam != null ? this.dateParam.toString() : java.time.LocalDate.now().toString(); 281 this.titleLabel.setText(baseTitle + " - " + dateStr); 282 } catch (Exception ex) { 283 this.titleLabel.setText(baseTitle); 284 } 285 } 286}