diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/RecorderApp.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/RecorderApp.java index ff68649..9feedd7 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/RecorderApp.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/RecorderApp.java @@ -1,7 +1,7 @@ package com.github.andrewlalis.running_every_day; import com.formdev.flatlaf.FlatLightLaf; -import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.db.DataSource; import com.github.andrewlalis.running_every_day.view.RecorderAppWindow; import com.github.andrewlalis.running_every_day.view.WindowDataSourceCloser; diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecord.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecord.java index 0cecfed..0cfefac 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecord.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecord.java @@ -1,5 +1,7 @@ package com.github.andrewlalis.running_every_day.data; +import com.github.andrewlalis.running_every_day.data.db.ResultSetMapper; + import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.ResultSet; @@ -46,6 +48,16 @@ public record RunRecord( return weightKg.multiply(BigDecimal.valueOf(1000)); } + public Duration averagePacePerKm() { + long msPerKm = (long) Math.floor(duration.toMillis() / distanceKm.doubleValue()); + return Duration.ofMillis(msPerKm); + } + + public String averagePacePerKmFormatted() { + var dur = averagePacePerKm(); + return String.format("%02d:%02d.%03d", dur.toMinutesPart(), dur.toSecondsPart(), dur.toMillisPart()); + } + public static class Mapper implements ResultSetMapper { @Override public RunRecord map(ResultSet rs) throws SQLException { diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java index 8c6e94a..211ae73 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java @@ -1,11 +1,22 @@ package com.github.andrewlalis.running_every_day.data; +import com.github.andrewlalis.running_every_day.data.db.Page; +import com.github.andrewlalis.running_every_day.data.db.Pagination; +import com.github.andrewlalis.running_every_day.data.db.Queries; + +import java.math.BigDecimal; +import java.math.RoundingMode; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.time.Duration; import java.util.ArrayList; import java.util.List; +/** + * Data access object for interacting with run records in the database. + * @param conn The database connection. + */ public record RunRecordRepository(Connection conn) { public Page findAll(Pagination pagination) throws SQLException { String query = "SELECT * FROM run ORDER BY date ASC"; @@ -62,22 +73,20 @@ public record RunRecordRepository(Connection conn) { } } - public long countAll() throws SQLException { - String query = "SELECT COUNT(id) FROM run"; - try ( - var stmt = conn.prepareStatement(query); - var rs = stmt.executeQuery() - ) { - if (rs.next()) { - return rs.getLong(1); - } else { - throw new SQLException("Missing count."); - } - } + public long pageCount(int pageSize) throws SQLException { + return Queries.pageCount(conn, "SELECT COUNT(id) FROM run", pageSize); } - public long pageCount(int size) throws SQLException { - long recordCount = countAll(); - return (recordCount / size) + (recordCount % size != 0 ? 1 : 0); + // Aggregate stats: + + public BigDecimal getTotalDistanceKm() throws SQLException { + long totalDistanceMeters = Queries.getLong(conn, "SELECT SUM(distance) FROM run"); + return BigDecimal.valueOf(totalDistanceMeters, 3) + .divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY); + } + + public Duration getTotalDuration() throws SQLException { + long totalDurationSeconds = Queries.getLong(conn, "SELECT SUM(duration) FROM run"); + return Duration.ofSeconds(totalDurationSeconds); } } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/DataSource.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/DataSource.java similarity index 79% rename from recorder/src/main/java/com/github/andrewlalis/running_every_day/data/DataSource.java rename to recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/DataSource.java index fbc111c..2307b37 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/DataSource.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/DataSource.java @@ -1,4 +1,6 @@ -package com.github.andrewlalis.running_every_day.data; +package com.github.andrewlalis.running_every_day.data.db; + +import com.github.andrewlalis.running_every_day.data.RunRecordRepository; import java.io.IOException; import java.io.UncheckedIOException; @@ -6,8 +8,6 @@ import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; /** * A single object that serves as the application's data source. @@ -24,14 +24,8 @@ public class DataSource { return new RunRecordRepository(this.conn); } - public List query(String query, ResultSetMapper mapper) throws SQLException { - try (var stmt = conn.prepareStatement(query); var rs = stmt.executeQuery()) { - List items = new ArrayList<>(); - while (rs.next()) { - items.add(mapper.map(rs)); - } - return items; - } + public Connection conn() { + return conn; } public void close() throws SQLException { @@ -64,7 +58,7 @@ public class DataSource { } } - private static String readResource(String name) { + public static String readResource(String name) { try (var in = DataSource.class.getClassLoader().getResourceAsStream(name)) { if (in == null) { throw new RuntimeException("Missing resource: " + name); diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Page.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Page.java similarity index 90% rename from recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Page.java rename to recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Page.java index 9cdaa7c..5ff474a 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Page.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Page.java @@ -1,4 +1,4 @@ -package com.github.andrewlalis.running_every_day.data; +package com.github.andrewlalis.running_every_day.data.db; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Pagination.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Pagination.java similarity index 96% rename from recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Pagination.java rename to recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Pagination.java index d937427..71ad877 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Pagination.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Pagination.java @@ -1,4 +1,4 @@ -package com.github.andrewlalis.running_every_day.data; +package com.github.andrewlalis.running_every_day.data.db; import java.sql.Connection; import java.sql.SQLException; diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Queries.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Queries.java new file mode 100644 index 0000000..54a3069 --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/Queries.java @@ -0,0 +1,74 @@ +package com.github.andrewlalis.running_every_day.data.db; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +public final class Queries { + private Queries() {} + + public static Optional findOne( + Connection c, + String query, + StatementModifier modifier, + ResultSetMapper mapper + ) throws SQLException { + try (var stmt = c.prepareStatement(query)) { + modifier.apply(stmt); + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(mapper.map(rs)); + } + return Optional.empty(); + } + } + } + + public static Optional findOne(Connection c, String query, ResultSetMapper mapper) throws SQLException { + try (var stmt = c.prepareStatement(query)) { + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(mapper.map(rs)); + } + return Optional.empty(); + } + } + } + + public static T getOne(Connection c, String query, ResultSetMapper mapper) throws SQLException { + return findOne(c, query, mapper).orElseThrow(() -> new SQLException("Missing required SQL result.")); + } + + public static long getLong(Connection c, String query) throws SQLException { + return getOne(c, query, rs -> rs.getLong(1)); + } + + public static String getString(Connection c, String query) throws SQLException { + return getOne(c, query, rs -> rs.getString(1)); + } + + public static long count(Connection c, String query) throws SQLException { + try (var stmt = c.prepareStatement(query); var rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getLong(1); + } else { + throw new SQLException("Result set for count query is empty."); + } + } + } + + public static long pageCount(Connection c, String query, int pageSize) throws SQLException { + long recordCount = count(c, query); + long pageCount = recordCount / pageSize; + if (recordCount % pageSize != 0) { + pageCount++; + } + return pageCount; + } + + public static int update(Connection c, String query) throws SQLException { + try (var stmt = c.prepareStatement(query)) { + return stmt.executeUpdate(); + } + } +} diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/ResultSetMapper.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/ResultSetMapper.java similarity index 73% rename from recorder/src/main/java/com/github/andrewlalis/running_every_day/data/ResultSetMapper.java rename to recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/ResultSetMapper.java index af1804f..90aa093 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/ResultSetMapper.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/ResultSetMapper.java @@ -1,4 +1,4 @@ -package com.github.andrewlalis.running_every_day.data; +package com.github.andrewlalis.running_every_day.data.db; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/StatementModifier.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/StatementModifier.java new file mode 100644 index 0000000..bdd0bb4 --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/db/StatementModifier.java @@ -0,0 +1,9 @@ +package com.github.andrewlalis.running_every_day.data.db; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +@FunctionalInterface +public interface StatementModifier { + void apply(PreparedStatement stmt) throws SQLException; +} diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java index 4e994fb..8122d4d 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AddRunRecordDialog.java @@ -1,6 +1,6 @@ package com.github.andrewlalis.running_every_day.view; -import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.db.DataSource; import com.github.andrewlalis.running_every_day.data.RunRecord; import javax.swing.*; @@ -15,7 +15,6 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; public class AddRunRecordDialog extends JDialog { private final DataSource dataSource; diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AggregateStatisticsPanel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AggregateStatisticsPanel.java index bf936c7..3fda96d 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AggregateStatisticsPanel.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/AggregateStatisticsPanel.java @@ -1,4 +1,55 @@ package com.github.andrewlalis.running_every_day.view; -public class AggregateStatisticsPanel { +import com.github.andrewlalis.running_every_day.data.db.DataSource; + +import javax.swing.*; +import java.awt.*; +import java.sql.SQLException; + +public class AggregateStatisticsPanel extends JPanel { + private final DataSource dataSource; + + private final JTextField totalDistanceField; + private final JTextField totalDurationField; + + public AggregateStatisticsPanel(DataSource dataSource) { + super(new BorderLayout()); + this.dataSource = dataSource; + + totalDistanceField = new JTextField(); + totalDistanceField.setEditable(false); + totalDurationField = new JTextField(); + totalDurationField.setEditable(false); + + JPanel statsPanel = new JPanel(new GridLayout(2, 2)); + statsPanel.add(new JLabel("Total Distance (Km)")); + statsPanel.add(totalDistanceField); + statsPanel.add(new JLabel("Total Duration")); + statsPanel.add(totalDurationField); + this.add(statsPanel, BorderLayout.CENTER); + + JPanel controlsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton refreshButton = new JButton("Refresh"); + refreshButton.addActionListener(e -> refreshStats()); + controlsPanel.add(refreshButton); + this.add(controlsPanel, BorderLayout.NORTH); + + SwingUtilities.invokeLater(this::refreshStats); + } + + private void refreshStats() { + try { + totalDistanceField.setText(dataSource.runRecords().getTotalDistanceKm().toPlainString() + " Km"); + var totalDuration = dataSource.runRecords().getTotalDuration(); + String durationStr = String.format( + "%d days, %d hours, %d minutes", + totalDuration.toDaysPart(), + totalDuration.toHoursPart(), + totalDuration.toMinutesPart() + ); + totalDurationField.setText(durationStr); + } catch (SQLException e) { + e.printStackTrace(); + } + } } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java index 780a1fd..9177be4 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RecorderAppWindow.java @@ -1,6 +1,6 @@ package com.github.andrewlalis.running_every_day.view; -import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.db.DataSource; import javax.swing.*; import java.awt.*; @@ -18,7 +18,7 @@ public class RecorderAppWindow extends JFrame { private Container buildGui(DataSource dataSource) { JTabbedPane tabbedPane = new JTabbedPane(); tabbedPane.addTab("Run Records", new RunRecordsPanel(dataSource)); - tabbedPane.addTab("Aggregate Statistics", new JPanel()); + tabbedPane.addTab("Aggregate Statistics", new AggregateStatisticsPanel(dataSource)); tabbedPane.addTab("Charts", new ChartsPanel()); return tabbedPane; } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java index c59157f..fb3aaa0 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java @@ -1,8 +1,8 @@ package com.github.andrewlalis.running_every_day.view; -import com.github.andrewlalis.running_every_day.data.DataSource; -import com.github.andrewlalis.running_every_day.data.Pagination; import com.github.andrewlalis.running_every_day.data.RunRecord; +import com.github.andrewlalis.running_every_day.data.db.DataSource; +import com.github.andrewlalis.running_every_day.data.db.Pagination; import javax.swing.table.AbstractTableModel; import java.sql.SQLException; @@ -88,7 +88,7 @@ public class RunRecordTableModel extends AbstractTableModel { @Override public int getColumnCount() { - return 7; + return 8; } @Override @@ -101,8 +101,9 @@ public class RunRecordTableModel extends AbstractTableModel { case 2 -> r.startTime().toString(); case 3 -> r.distanceKm().toPlainString(); case 4 -> r.durationFormatted(); - case 5 -> r.weightKg().toPlainString(); - case 6 -> r.comment(); + case 5 -> r.averagePacePerKmFormatted(); + case 6 -> r.weightKg().toPlainString(); + case 7 -> r.comment(); default -> null; }; } @@ -115,8 +116,9 @@ public class RunRecordTableModel extends AbstractTableModel { case 2 -> "Start Time"; case 3 -> "Distance (Km)"; case 4 -> "Duration"; - case 5 -> "Weight (Kg)"; - case 6 -> "Comment"; + case 5 -> "Pace (Min/Km)"; + case 6 -> "Weight (Kg)"; + case 7 -> "Comment"; default -> "Unknown Value"; }; } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java index d8ee9e6..ea27bb8 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java @@ -1,15 +1,26 @@ package com.github.andrewlalis.running_every_day.view; -import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.RunRecord; +import com.github.andrewlalis.running_every_day.data.db.DataSource; +import com.github.andrewlalis.running_every_day.data.db.Queries; import javax.swing.*; import java.awt.*; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.SQLException; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Random; /** * A panel for displaying a table view of run records, and various controls for * navigating the data. */ public class RunRecordsPanel extends JPanel { + private final DataSource dataSource; + private final RunRecordTableModel tableModel; private final JTextField currentPageField; private final JButton firstPageButton; @@ -19,19 +30,22 @@ public class RunRecordsPanel extends JPanel { public RunRecordsPanel(DataSource dataSource) { super(new BorderLayout()); + this.dataSource = dataSource; this.tableModel = new RunRecordTableModel(dataSource); var table = new JTable(tableModel); table.getTableHeader().setReorderingAllowed(false); table.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN); - table.getColumnModel().getColumn(0).setMaxWidth(40); + table.getColumnModel().getColumn(0).setMaxWidth(50); table.getColumnModel().getColumn(1).setMaxWidth(80); - table.getColumnModel().getColumn(2).setMaxWidth(80); + table.getColumnModel().getColumn(2).setMaxWidth(70); table.getColumnModel().getColumn(3).setMaxWidth(100); table.getColumnModel().getColumn(3).setPreferredWidth(100); - table.getColumnModel().getColumn(4).setMaxWidth(80); - table.getColumnModel().getColumn(5).setMaxWidth(80); - for (int i = 0; i < 6; i++) { + table.getColumnModel().getColumn(4).setMaxWidth(60); + table.getColumnModel().getColumn(5).setMaxWidth(100); + table.getColumnModel().getColumn(5).setPreferredWidth(100); + table.getColumnModel().getColumn(6).setMaxWidth(80); + for (int i = 0; i < 7; i++) { table.getColumnModel().getColumn(i).setResizable(false); } var scrollPane = new JScrollPane(table, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); @@ -98,6 +112,9 @@ public class RunRecordsPanel extends JPanel { } }); actionsPanel.add(addActionButton); + JButton generateRandomDataButton = new JButton("Generate Random Data"); + generateRandomDataButton.addActionListener(e -> generateRandomData()); + actionsPanel.add(generateRandomDataButton); this.add(actionsPanel, BorderLayout.NORTH); } @@ -108,4 +125,39 @@ public class RunRecordsPanel extends JPanel { lastPageButton.setEnabled(tableModel.canGoToLastPage()); currentPageField.setText(String.valueOf(tableModel.getPagination().page() + 1)); } + + private void generateRandomData() { + try { + var c = dataSource.conn(); + Queries.update(c, "DELETE FROM run"); + final int daysToDo = 1000; + LocalDate date = LocalDate.now().minusDays(daysToDo); + Random rand = new Random(); + c.setAutoCommit(false); + for (int i = 0; i < daysToDo; i++) { + LocalTime startTime = LocalTime.of(rand.nextInt(5, 22), rand.nextInt(60)); + BigDecimal distance = BigDecimal.valueOf(rand.nextInt(1000, 15000), 3) + .divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY); + double avgPage = rand.nextDouble(300.0, 700.0); + BigDecimal weight = BigDecimal.valueOf(rand.nextInt(75000, 110000), 3) + .divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY); + dataSource.runRecords().save(new RunRecord( + 0, + date, + startTime, + distance, + Duration.ofSeconds((long) Math.floor(avgPage * distance.doubleValue())), + weight, + "Bleh" + )); + date = date.plusDays(1); + } + c.commit(); + c.setAutoCommit(true); + tableModel.firstPage(); + updateButtonStates(); + } catch (SQLException e) { + e.printStackTrace(); + } + } } diff --git a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/WindowDataSourceCloser.java b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/WindowDataSourceCloser.java index 002a57f..5f80c93 100644 --- a/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/WindowDataSourceCloser.java +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/WindowDataSourceCloser.java @@ -1,6 +1,6 @@ package com.github.andrewlalis.running_every_day.view; -import com.github.andrewlalis.running_every_day.data.DataSource; +import com.github.andrewlalis.running_every_day.data.db.DataSource; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent;