Added more query utils, aggregate stats, and randomized testing data.

This commit is contained in:
Andrew Lalis 2023-04-20 10:57:13 +02:00
parent 581eebadbd
commit bb5370e92a
15 changed files with 252 additions and 50 deletions

View File

@ -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;

View File

@ -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<RunRecord> {
@Override
public RunRecord map(ResultSet rs) throws SQLException {

View File

@ -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<RunRecord> 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);
}
}

View File

@ -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 <T> List<T> query(String query, ResultSetMapper<T> mapper) throws SQLException {
try (var stmt = conn.prepareStatement(query); var rs = stmt.executeQuery()) {
List<T> 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);

View File

@ -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;

View File

@ -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;

View File

@ -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 <T> Optional<T> findOne(
Connection c,
String query,
StatementModifier modifier,
ResultSetMapper<T> 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 <T> Optional<T> findOne(Connection c, String query, ResultSetMapper<T> 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> T getOne(Connection c, String query, ResultSetMapper<T> 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();
}
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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();
}
}
}

View File

@ -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;
}

View File

@ -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";
};
}

View File

@ -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();
}
}
}

View File

@ -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;