From c94bac792c95de1812f1c1338e168cc86cd95058 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Wed, 19 Apr 2023 14:47:07 +0200 Subject: [PATCH] Added basic data for run records, and table view with pagination. --- .../running_every_day/RecorderApp.java | 14 +-- .../running_every_day/data/DataSource.java | 30 +++-- .../running_every_day/data/Page.java | 19 ++++ .../running_every_day/data/Pagination.java | 51 +++++++++ .../data/ResultSetMapper.java | 9 ++ .../running_every_day/data/RunRecord.java | 39 ++++++- .../data/RunRecordRepository.java | 83 ++++++++++++++ .../view/RecorderAppWindow.java | 20 +++- .../view/RunRecordTableModel.java | 63 +++++++++++ .../view/RunRecordsPanel.java | 104 ++++++++++++++++++ recorder/src/main/resources/schema.sql | 2 +- 11 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Page.java create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Pagination.java create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/data/ResultSetMapper.java create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java create mode 100644 recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java 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 7bfde79..4ec630b 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 @@ -10,14 +10,10 @@ import java.sql.SQLException; * The main application entrypoint. */ public class RecorderApp { - public static void main(String[] args) { - try (var dataSource = new DataSource("jdbc:sqlite:runs.db")) { - FlatLightLaf.setup(); - var window = new RecorderAppWindow(dataSource); - window.setVisible(true); - } catch (SQLException e) { - System.err.println("An SQL error occurred: " + e.getMessage()); - e.printStackTrace(); - } + public static void main(String[] args) throws SQLException { + DataSource dataSource = new DataSource("jdbc:sqlite:runs.db"); + FlatLightLaf.setup(); + var window = new RecorderAppWindow(dataSource); + window.setVisible(true); } } 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/DataSource.java index 456754d..a622dc1 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/DataSource.java @@ -3,12 +3,11 @@ package com.github.andrewlalis.running_every_day.data; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; -public class DataSource implements AutoCloseable { +public class DataSource { private final Connection conn; public DataSource(String url) throws SQLException { @@ -16,17 +15,32 @@ public class DataSource implements AutoCloseable { this.initSchemaIfNeeded(); } + public RunRecordRepository runRecords() { + return new RunRecordRepository(this.conn); + } + public void close() throws SQLException { this.conn.close(); } + /** + * Checks for any missing tables and initializes the database schema with + * them if so. + * @throws SQLException If an SQL error occurs. + */ private void initSchemaIfNeeded() throws SQLException { - boolean shouldInitSchema; - try ( - var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='run'"); - var rs = stmt.executeQuery() - ) { - shouldInitSchema = !rs.next(); + boolean shouldInitSchema = false; + final String[] tableNames = {"run"}; + try (var stmt = this.conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name=?")) { + for (var name : tableNames) { + stmt.setString(1, name); + try (var rs = stmt.executeQuery()) { + if (!rs.next()) { + shouldInitSchema = true; + break; + } + } + } } if (shouldInitSchema) { try (var stmt = this.conn.createStatement()) { 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/Page.java new file mode 100644 index 0000000..9cdaa7c --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Page.java @@ -0,0 +1,19 @@ +package com.github.andrewlalis.running_every_day.data; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public record Page( + List items, + Pagination pagination +) { + public static Page fromResultSet(ResultSet rs, ResultSetMapper mapper, Pagination pagination) throws SQLException { + List items = new ArrayList<>(pagination.size()); + while (rs.next() && items.size() < pagination.size()) { + items.add(mapper.map(rs)); + } + return new Page<>(items, pagination); + } +} 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/Pagination.java new file mode 100644 index 0000000..d937427 --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/Pagination.java @@ -0,0 +1,51 @@ +package com.github.andrewlalis.running_every_day.data; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.List; + +public record Pagination(int page, int size, List sorts) { + public record Sort(String property, SortDir direction, NullsDir nullsDirection) { + public Sort(String property, SortDir direction) { + this(property, direction, NullsDir.FIRST); + } + + public Sort(String property) { + this(property, SortDir.ASC); + } + } + public enum SortDir {ASC, DESC} + public enum NullsDir {FIRST, LAST} + + public Pagination(int page, int size) { + this(page, size, Collections.emptyList()); + } + + public Pagination nextPage() { + return new Pagination(this.page + 1, this.size, this.sorts); + } + + public Pagination previousPage() { + return new Pagination(this.page - 1, this.size, this.sorts); + } + + public String toQuerySyntax() { + StringBuilder sb = new StringBuilder(256); + List orderingTerms = this.sorts.stream() + .map(s -> s.property + ' ' + s.direction.name()) + .toList(); + if (!orderingTerms.isEmpty()) { + sb.append(String.join(",", orderingTerms)).append(' '); + } + sb.append("LIMIT ").append(this.size).append(" OFFSET ").append(this.page * this.size); + return sb.toString(); + } + + public Page execute(Connection conn, String baseQuery, ResultSetMapper mapper) throws SQLException { + String query = baseQuery + ' ' + this.toQuerySyntax(); + try (var stmt = conn.prepareStatement(query)) { + return Page.fromResultSet(stmt.executeQuery(), mapper, this); + } + } +} 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/ResultSetMapper.java new file mode 100644 index 0000000..af1804f --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/ResultSetMapper.java @@ -0,0 +1,9 @@ +package com.github.andrewlalis.running_every_day.data; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@FunctionalInterface +public interface ResultSetMapper { + T map(ResultSet rs) throws SQLException; +} 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 8c99fed..d3422e8 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,6 +1,9 @@ package com.github.andrewlalis.running_every_day.data; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.Duration; import java.time.LocalDate; import java.time.LocalTime; @@ -23,4 +26,38 @@ public record RunRecord( Duration duration, BigDecimal weightKg, String comment -) {} +) { + public BigDecimal distanceMeters() { + return distanceKm.multiply(BigDecimal.valueOf(1000)); + } + + public int durationSeconds () { + return (int) duration.getSeconds(); + } + + public BigDecimal weightGrams() { + return weightKg.multiply(BigDecimal.valueOf(1000)); + } + + public static class Mapper implements ResultSetMapper { + @Override + public RunRecord map(ResultSet rs) throws SQLException { + long id = rs.getLong("id"); + String dateStr = rs.getString("date"); + String startTimeStr = rs.getString("start_time"); + int distanceMeters = rs.getInt("distance"); + int durationSeconds = rs.getInt("duration"); + int weightGrams = rs.getInt("weight"); + String comment = rs.getString("comment"); + return new RunRecord( + id, + LocalDate.parse(dateStr), + LocalTime.parse(startTimeStr), + BigDecimal.valueOf(distanceMeters, 3).divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY), + Duration.ofSeconds(durationSeconds), + BigDecimal.valueOf(weightGrams, 3).divide(BigDecimal.valueOf(1000, 3), RoundingMode.UNNECESSARY), + comment + ); + } + } +} 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 new file mode 100644 index 0000000..d74d2df --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/data/RunRecordRepository.java @@ -0,0 +1,83 @@ +package com.github.andrewlalis.running_every_day.data; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +public record RunRecordRepository(Connection conn) { + public Page findAll(Pagination pagination) throws SQLException { + String query = "SELECT id, date, start_time, distance, duration, weight, comment FROM run"; + return pagination.execute(conn, query, new RunRecord.Mapper()); + } + + public List findAllByQuery(String query) throws SQLException { + List items = new ArrayList<>(); + var mapper = new RunRecord.Mapper(); + try ( + var stmt = conn.prepareStatement(query); + var rs = stmt.executeQuery() + ) { + while (rs.next()) { + items.add(mapper.map(rs)); + } + } + return items; + } + + public void delete(long id) throws SQLException { + try (var stmt = conn.prepareStatement("DELETE FROM run WHERE id = ?")) { + stmt.setLong(1, id); + stmt.executeUpdate(); + } + } + + public RunRecord save(RunRecord runRecord) throws SQLException { + String query = "INSERT INTO run (date, start_time, distance, duration, weight, comment) VALUES (?, ?, ?, ?, ?, ?)"; + try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { + stmt.setString(1, runRecord.date().toString()); + stmt.setString(2, runRecord.startTime().toString()); + stmt.setInt(3, runRecord.distanceMeters().intValue()); + stmt.setInt(4, runRecord.durationSeconds()); + stmt.setInt(5, runRecord.weightGrams().intValue()); + stmt.setString(6, runRecord.comment()); + stmt.executeUpdate(); + try (var rs = stmt.getGeneratedKeys()) { + if (rs.next()) { + long id = rs.getLong(1); + return new RunRecord( + id, + runRecord.date(), + runRecord.startTime(), + runRecord.distanceKm(), + runRecord.duration(), + runRecord.weightKg(), + runRecord.comment() + ); + } else { + throw new SQLException("Missing generated primary key for record."); + } + } + } + } + + 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 size) throws SQLException { + long recordCount = countAll(); + return (recordCount / size) + (recordCount % size != 0 ? 1 : 0); + } +} 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 26a22ca..9400c8e 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 @@ -9,9 +9,25 @@ public class RecorderAppWindow extends JFrame { public RecorderAppWindow(DataSource dataSource) { super("Run-Recorder"); this.setDefaultCloseOperation(DISPOSE_ON_CLOSE); - // TODO: Build UI - this.setPreferredSize(new Dimension(800, 600)); + this.setContentPane(buildGui(dataSource)); + this.setPreferredSize(new Dimension(1000, 600)); this.pack(); this.setLocationRelativeTo(null); } + + private Container buildGui(DataSource dataSource) { + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Run Records", new RunRecordsPanel(dataSource)); + tabbedPane.addTab("Aggregate Statistics", buildAggregateStatisticsPanel(dataSource)); + tabbedPane.addTab("Charts", buildChartsPanel(dataSource)); + return tabbedPane; + } + + private Container buildAggregateStatisticsPanel(DataSource dataSource) { + return new JPanel(); + } + + private Container buildChartsPanel(DataSource dataSource) { + return new JPanel(); + } } 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 new file mode 100644 index 0000000..8176a7f --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordTableModel.java @@ -0,0 +1,63 @@ +package com.github.andrewlalis.running_every_day.view; + +import com.github.andrewlalis.running_every_day.data.Page; +import com.github.andrewlalis.running_every_day.data.RunRecord; + +import javax.swing.table.AbstractTableModel; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.List; + +public class RunRecordTableModel extends AbstractTableModel { + private List records; + + public RunRecordTableModel() { + this.records = new ArrayList<>(0); + } + + public void setPage(Page page) { + this.records = page.items(); + this.fireTableDataChanged(); + } + + @Override + public int getRowCount() { + return records.size(); + } + + @Override + public int getColumnCount() { + return 7; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + if (rowIndex < 0 || rowIndex >= records.size()) return null; + RunRecord r = records.get(rowIndex); + DecimalFormat singleDigitFormat = new DecimalFormat("#,##0.0"); + return switch (columnIndex) { + case 0 -> Long.toString(r.id()); + case 1 -> r.date().toString(); + case 2 -> r.startTime().toString(); + case 3 -> r.distanceKm().toPlainString(); + case 4 -> r.duration().toString(); + case 5 -> r.weightKg().toPlainString(); + case 6 -> r.comment(); + default -> null; + }; + } + + @Override + public String getColumnName(int column) { + return switch (column) { + case 0 -> "Id"; + case 1 -> "Date"; + case 2 -> "Start Time"; + case 3 -> "Distance (Km)"; + case 4 -> "Duration"; + case 5 -> "Weight (Kg)"; + case 6 -> "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 new file mode 100644 index 0000000..9c79284 --- /dev/null +++ b/recorder/src/main/java/com/github/andrewlalis/running_every_day/view/RunRecordsPanel.java @@ -0,0 +1,104 @@ +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 javax.swing.*; +import java.awt.*; +import java.sql.SQLException; + +/** + * 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 = new RunRecordTableModel(); + private final JTextField currentPageField; + private final JComboBox pageSizeSelector; + private final JButton firstPageButton; + private final JButton previousPageButton; + private final JButton nextPageButton; + private final JButton lastPageButton; + + private Pagination currentPage; + public RunRecordsPanel(DataSource dataSource) { + super(new BorderLayout()); + this.dataSource = dataSource; + this.currentPage = new Pagination(0, 20); + + var table = new JTable(tableModel); + var scrollPane = new JScrollPane(table, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + this.add(scrollPane, BorderLayout.CENTER); + + JPanel paginationPanel = new JPanel(new GridLayout(1, 6)); + firstPageButton = new JButton("First Page"); + firstPageButton.addActionListener(e -> { + currentPage = new Pagination(0, currentPage.size(), currentPage.sorts()); + loadPage(); + }); + previousPageButton = new JButton("Previous Page"); + previousPageButton.addActionListener(e -> { + currentPage = currentPage.previousPage(); + loadPage(); + }); + nextPageButton = new JButton("Next Page"); + nextPageButton.addActionListener(e -> { + currentPage = currentPage.nextPage(); + loadPage(); + }); + lastPageButton = new JButton("Last Page"); + lastPageButton.addActionListener(e -> { + try { + long pageCount = dataSource.runRecords().pageCount(currentPage.size()); + if (pageCount <= 0) pageCount = 1; + currentPage = new Pagination((int) (pageCount - 1), currentPage.size(), currentPage.sorts()); + loadPage(); + } catch (SQLException ex) { + ex.printStackTrace(); + } + }); + + JPanel currentPagePanel = new JPanel(); + currentPagePanel.add(new JLabel("Current Page: ")); + this.currentPageField = new JTextField("1", 3); + // TODO: Add document change listener. + currentPagePanel.add(this.currentPageField); + + JPanel pageSizePanel = new JPanel(); + pageSizePanel.add(new JLabel("Page Size: ")); + pageSizeSelector = new JComboBox<>(new Integer[]{5, 10, 20, 50, 100, 500}); + pageSizeSelector.setSelectedItem(this.currentPage.size()); + pageSizeSelector.addItemListener(e -> { + currentPage = new Pagination(0, (Integer) e.getItem(), currentPage.sorts()); + loadPage(); + }); + pageSizePanel.add(pageSizeSelector); + + + paginationPanel.add(firstPageButton); + paginationPanel.add(previousPageButton); + paginationPanel.add(currentPagePanel); + paginationPanel.add(pageSizePanel); + paginationPanel.add(nextPageButton); + paginationPanel.add(lastPageButton); + + this.add(paginationPanel, BorderLayout.SOUTH); + + SwingUtilities.invokeLater(this::loadPage); + } + + private void loadPage() { + try { + this.tableModel.setPage(dataSource.runRecords().findAll(this.currentPage)); + long pageCount = dataSource.runRecords().pageCount(this.currentPage.size()); + this.firstPageButton.setEnabled(this.currentPage.page() != 0); + this.previousPageButton.setEnabled(this.currentPage.page() > 0); + this.nextPageButton.setEnabled(this.currentPage.page() < pageCount - 1); + this.lastPageButton.setEnabled(this.currentPage.page() != pageCount - 1); + this.currentPageField.setText(String.valueOf(this.currentPage.page() + 1)); + } catch (SQLException e) { + e.printStackTrace(); + } + } +} diff --git a/recorder/src/main/resources/schema.sql b/recorder/src/main/resources/schema.sql index 68aa59c..8725e0d 100644 --- a/recorder/src/main/resources/schema.sql +++ b/recorder/src/main/resources/schema.sql @@ -1,4 +1,4 @@ -CREATE TABLE run ( +CREATE TABLE IF NOT EXISTS run ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, start_time TEXT,