From 970ca46ef65d3ea0ca8306b3b8a85e9caf180334 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Tue, 6 Feb 2024 09:16:12 -0500 Subject: [PATCH] Cleaned up account history, improved transaction tiles in dashboard and transactions list, and fixed small bug in balance record validation. --- .../perfin/control/AccountViewController.java | 40 +---- .../CreateBalanceRecordController.java | 18 ++- .../perfin/data/AccountEntryRepository.java | 2 + .../perfin/data/AccountRepository.java | 3 + .../perfin/data/BalanceRecordRepository.java | 1 + .../perfin/data/HistoryRepository.java | 2 + .../data/impl/JdbcAccountEntryRepository.java | 19 ++- .../data/impl/JdbcAccountRepository.java | 53 ++++++- .../impl/JdbcBalanceRecordRepository.java | 14 +- .../data/impl/JdbcDataSourceFactory.java | 13 ++ .../data/impl/JdbcHistoryRepository.java | 15 +- .../perfin/model/AccountEntry.java | 2 +- .../perfin/model/BalanceRecord.java | 2 +- .../andrewlalis/perfin/model/Timestamped.java | 26 ++++ .../andrewlalis/perfin/model/Transaction.java | 2 +- .../perfin/model/history/HistoryItem.java | 13 +- .../perfin/model/history/HistoryTextItem.java | 2 +- .../component/AccountHistoryItemTile.java | 29 ---- .../component/AccountHistoryTextTile.java | 12 -- .../view/component/AccountHistoryTile.java | 20 +++ .../view/component/AccountHistoryView.java | 146 ++++++++++++++++++ .../perfin/view/component/CategoryLabel.java | 15 ++ .../view/component/TransactionTile.java | 39 +++-- .../module/RecentTransactionsModule.java | 29 +++- src/main/resources/account-view.fxml | 26 +--- src/main/resources/style/base.css | 6 + 26 files changed, 405 insertions(+), 144 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/model/Timestamped.java delete mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java delete mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java create mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTile.java create mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryView.java create mode 100644 src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 5241538..07d4fa7 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -2,25 +2,18 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.AccountRepository; -import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.history.HistoryItem; -import com.andrewlalis.perfin.view.component.AccountHistoryItemTile; -import javafx.application.Platform; +import com.andrewlalis.perfin.view.component.AccountHistoryView; import javafx.beans.binding.BooleanExpression; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.fxml.FXML; -import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.layout.VBox; -import java.time.LocalDateTime; -import java.util.List; - import static com.andrewlalis.perfin.PerfinApp.router; public class AccountViewController implements RouteSelectionListener { @@ -35,10 +28,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public Label accountBalanceLabel; @FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false); - @FXML public VBox historyItemsVBox; - @FXML public Button loadMoreHistoryButton; - private LocalDateTime loadHistoryFrom; - private final int historyLoadSize = 5; + @FXML public AccountHistoryView accountHistory; @FXML public VBox actionsVBox; @@ -66,15 +56,9 @@ public class AccountViewController implements RouteSelectionListener { accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt())); Profile.getCurrent().dataSource().getAccountBalanceText(account) .thenAccept(accountBalanceLabel::setText); - - reloadHistory(); - } - - public void reloadHistory() { - loadHistoryFrom = DateUtil.nowAsUTC(); - historyItemsVBox.getChildren().clear(); - loadMoreHistoryButton.setDisable(false); - loadMoreHistory(); + accountHistory.clear(); + accountHistory.setAccountId(account.id); + accountHistory.loadMoreHistory(); } @FXML @@ -129,18 +113,4 @@ public class AccountViewController implements RouteSelectionListener { router.replace("accounts"); } } - - @FXML public void loadMoreHistory() { - Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> { - long historyId = repo.getOrCreateHistoryForAccount(account.id); - List items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom); - if (items.size() < historyLoadSize) { - Platform.runLater(() -> loadMoreHistoryButton.setDisable(true)); - } else { - loadHistoryFrom = items.getLast().getTimestamp(); - } - List nodes = items.stream().map(AccountHistoryItemTile::forItem).toList(); - Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes)); - }); - } } diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index 583aff0..b0a1978 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -24,6 +24,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import static com.andrewlalis.perfin.PerfinApp.router; @@ -56,16 +57,17 @@ public class CreateBalanceRecordController implements RouteSelectionListener { balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty()); balanceWarningLabel.visibleProperty().set(false); balanceField.textProperty().addListener((observable, oldValue, newValue) -> { - if (!balanceValidator.validate(newValue).isValid()) { + if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get()) { balanceWarningLabel.visibleProperty().set(false); return; } BigDecimal reportedBalance = new BigDecimal(newValue); + LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); + LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp); Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { - BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id); - Platform.runLater(() -> balanceWarningLabel.visibleProperty().set( - !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance) - )); + BigDecimal derivedBalance = repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC)); + boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance); + Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch)); }); }); @@ -95,7 +97,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) )); - if (confirm && confirmIfInconsistentBalance(reportedBalance)) { + if (confirm && confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp))) { Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> { repo.insert( DateUtil.localToUTC(localTimestamp), @@ -113,10 +115,10 @@ public class CreateBalanceRecordController implements RouteSelectionListener { router.navigateBackAndClear(); } - private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) { + private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) { BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo( AccountRepository.class, - repo -> repo.deriveCurrentBalance(account.id) + repo -> repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC)) ); if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) { String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted( diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java index c50c08c..9d9d99b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Currency; import java.util.List; +import java.util.Optional; public interface AccountEntryRepository extends Repository, AutoCloseable { long insert( @@ -16,6 +17,7 @@ public interface AccountEntryRepository extends Repository, AutoCloseable { AccountEntry.Type type, Currency currency ); + Optional findById(long id); List findAllByAccountId(long accountId); List findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax); } diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java index 0c5352c..0b59adb 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java @@ -4,10 +4,12 @@ import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountType; +import com.andrewlalis.perfin.model.Timestamped; import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; +import java.time.LocalDateTime; import java.util.Currency; import java.util.List; import java.util.Optional; @@ -32,4 +34,5 @@ public interface AccountRepository extends Repository, AutoCloseable { return deriveBalance(accountId, Instant.now(Clock.systemUTC())); } Set findAllUsedCurrencies(); + List findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults); } diff --git a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java index ee9050c..d4f3419 100644 --- a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java @@ -13,6 +13,7 @@ import java.util.Optional; public interface BalanceRecordRepository extends Repository, AutoCloseable { long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List attachments); BalanceRecord findLatestByAccountId(long accountId); + Optional findById(long id); Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp); Optional findClosestAfter(long accountId, LocalDateTime utcTimestamp); List findAttachments(long recordId); diff --git a/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java index 815f67d..4163974 100644 --- a/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java @@ -9,6 +9,7 @@ import com.andrewlalis.perfin.model.history.HistoryTextItem; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Optional; public interface HistoryRepository extends Repository, AutoCloseable { long getOrCreateHistoryForAccount(long accountId); @@ -20,6 +21,7 @@ public interface HistoryRepository extends Repository, AutoCloseable { default HistoryTextItem addTextItem(long historyId, String description) { return addTextItem(historyId, DateUtil.nowAsUTC(), description); } + Optional getItem(long id); Page getItems(long historyId, PageRequest pagination); List getNItemsBefore(long historyId, int n, LocalDateTime timestamp); default List getNItemsBeforeNow(long historyId, int n) { diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java index 0ec481b..fc93759 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -1,7 +1,6 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; -import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.AccountEntry; @@ -12,11 +11,12 @@ import java.sql.SQLException; import java.time.LocalDateTime; import java.util.Currency; import java.util.List; +import java.util.Optional; public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository { @Override public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) { - long entryId = DbUtil.insertOne( + return DbUtil.insertOne( conn, """ INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) @@ -30,11 +30,16 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr currency.getCurrencyCode() ) ); - // Insert an entry into the account's history. - HistoryRepository historyRepo = new JdbcHistoryRepository(conn); - long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); - historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + "."); - return entryId; + } + + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM account_entry WHERE id = ?", + id, + JdbcAccountEntryRepository::parse + ); } @Override diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 786dad8..6752f7b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -5,10 +5,7 @@ import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; -import com.andrewlalis.perfin.model.Account; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.AccountType; -import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -169,6 +166,54 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements )); } + @Override + public List findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults) { + var entryRepo = new JdbcAccountEntryRepository(conn); + var historyRepo = new JdbcHistoryRepository(conn); + var balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir); + String query = """ + SELECT id, type + FROM ( + SELECT id, timestamp, 'ACCOUNT_ENTRY' AS type, account_id + FROM account_entry + UNION ALL + SELECT id, timestamp, 'HISTORY_ITEM' AS type, account_id + FROM history_item + LEFT JOIN history_account ha ON history_item.history_id = ha.history_id + UNION ALL + SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id + FROM balance_record + ) + WHERE account_id = ? AND timestamp <= ? + ORDER BY timestamp DESC + LIMIT\s""" + maxResults; + try (var stmt = conn.prepareStatement(query)) { + stmt.setLong(1, accountId); + stmt.setTimestamp(2, DbUtil.timestampFromUtcLDT(utcTimestamp)); + ResultSet rs = stmt.executeQuery(); + List entities = new ArrayList<>(); + while (rs.next()) { + long id = rs.getLong(1); + String type = rs.getString(2); + Timestamped entity = switch (type) { + case "HISTORY_ITEM" -> historyRepo.getItem(id).orElse(null); + case "ACCOUNT_ENTRY" -> entryRepo.findById(id).orElse(null); + case "BALANCE_RECORD" -> balanceRecordRepo.findById(id).orElse(null); + default -> null; + }; + if (entity == null) { + log.warn("Failed to find entity with id {} and type {}.", id, type); + } else { + entities.add(entity); + } + } + return entities; + } catch (SQLException e) { + log.error("Failed to find account events.", e); + return Collections.emptyList(); + } + } + @Override public void update(Account account) { DbUtil.updateOne( diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java index da34639..cff24a8 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -37,10 +37,6 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl stmt.executeUpdate(); } } - // Add a history item entry. - HistoryRepository historyRepo = new JdbcHistoryRepository(conn); - long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); - historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency))); return recordId; }); } @@ -55,6 +51,16 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl ).orElse(null); } + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM balance_record WHERE id = ?", + id, + JdbcBalanceRecordRepository::parse + ); + } + @Override public Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp) { return DbUtil.findOne( diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index 0727cbf..4fb2141 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -113,6 +113,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory { */ public void insertDefaultData(Connection conn) throws IOException, SQLException { insertDefaultCategories(conn); + insertDefaultTags(conn); } public void insertDefaultCategories(Connection conn) throws IOException, SQLException { @@ -151,6 +152,18 @@ public class JdbcDataSourceFactory implements DataSourceFactory { } } + private void insertDefaultTags(Connection conn) throws SQLException { + final List defaultTags = List.of( + "!exclude" + ); + try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag (name) VALUES (?)")) { + for (var tag : defaultTags) { + stmt.setString(1, tag); + stmt.executeUpdate(); + } + } + } + private boolean testConnection(JdbcDataSource dataSource) { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { return stmt.execute("SELECT 1;"); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java index 5626d38..4fdc420 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public record JdbcHistoryRepository(Connection conn) implements HistoryRepository { @Override @@ -56,7 +57,7 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor @Override public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) { - long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT); + long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.Type.TEXT.name()); DbUtil.updateOne( conn, "INSERT INTO history_item_text (id, description) VALUES (?, ?)", @@ -66,6 +67,16 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor return new HistoryTextItem(itemId, historyId, utcTimestamp, description); } + @Override + public Optional getItem(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM history_item WHERE id = ?", + id, + JdbcHistoryRepository::parseItem + ); + } + private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) { return DbUtil.insertOne( conn, @@ -111,7 +122,7 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor long historyId = rs.getLong(2); LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3)); String type = rs.getString(4); - if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) { + if (type.equalsIgnoreCase(HistoryItem.Type.TEXT.name())) { String description = DbUtil.findOne( rs.getStatement().getConnection(), "SELECT description FROM history_item_text WHERE id = ?", diff --git a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java index 47a9ab8..66f9fdd 100644 --- a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java +++ b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java @@ -30,7 +30,7 @@ import java.util.Currency; * all those extra accounts would be a burden to casual users. *

*/ -public class AccountEntry extends IdEntity { +public class AccountEntry extends IdEntity implements Timestamped { public enum Type { CREDIT, DEBIT diff --git a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java index 38e4841..6ea213b 100644 --- a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java +++ b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java @@ -9,7 +9,7 @@ import java.util.Currency; * used as a sanity check for ensuring that an account's entries add up to the * correct balance. */ -public class BalanceRecord extends IdEntity { +public class BalanceRecord extends IdEntity implements Timestamped { private final LocalDateTime timestamp; private final long accountId; private final BigDecimal balance; diff --git a/src/main/java/com/andrewlalis/perfin/model/Timestamped.java b/src/main/java/com/andrewlalis/perfin/model/Timestamped.java new file mode 100644 index 0000000..95a326a --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/Timestamped.java @@ -0,0 +1,26 @@ +package com.andrewlalis.perfin.model; + +import com.andrewlalis.perfin.data.util.DbUtil; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; + +public interface Timestamped { + /** + * Gets the timestamp at which the entity was created, in UTC timezone. + * @return The UTC timestamp at which this entity was created. + */ + LocalDateTime getTimestamp(); + + record Stub(long id, LocalDateTime timestamp) implements Timestamped { + @Override + public LocalDateTime getTimestamp() { + return timestamp; + } + + public static Stub fromResultSet(ResultSet rs) throws SQLException { + return new Stub(rs.getLong(1), DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2))); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java index 92588d8..5e8e892 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -12,7 +12,7 @@ import java.util.Currency; * actual positive/negative effect is determined by the associated account * entries that apply this transaction's amount to one or more accounts. */ -public class Transaction extends IdEntity { +public class Transaction extends IdEntity implements Timestamped { private final LocalDateTime timestamp; private final BigDecimal amount; private final Currency currency; diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java index 7e0d7b7..7abd367 100644 --- a/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java @@ -1,6 +1,7 @@ package com.andrewlalis.perfin.model.history; import com.andrewlalis.perfin.model.IdEntity; +import com.andrewlalis.perfin.model.Timestamped; import java.time.LocalDateTime; @@ -8,14 +9,16 @@ import java.time.LocalDateTime; * Represents a single polymorphic history item. The item's "type" attribute * tells where to find additional type-specific data. */ -public abstract class HistoryItem extends IdEntity { - public static final String TYPE_TEXT = "TEXT"; +public abstract class HistoryItem extends IdEntity implements Timestamped { + public enum Type { + TEXT + } private final long historyId; private final LocalDateTime timestamp; - private final String type; + private final Type type; - public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) { + public HistoryItem(long id, long historyId, LocalDateTime timestamp, Type type) { super(id); this.historyId = historyId; this.timestamp = timestamp; @@ -30,7 +33,7 @@ public abstract class HistoryItem extends IdEntity { return timestamp; } - public String getType() { + public Type getType() { return type; } } diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java index d325286..7d37add 100644 --- a/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java @@ -6,7 +6,7 @@ public class HistoryTextItem extends HistoryItem { private final String description; public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) { - super(id, historyId, timestamp, HistoryItem.TYPE_TEXT); + super(id, historyId, timestamp, HistoryItem.Type.TEXT); this.description = description; } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java deleted file mode 100644 index 853bb7b..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.history.HistoryItem; -import com.andrewlalis.perfin.model.history.HistoryTextItem; -import javafx.scene.control.Label; -import javafx.scene.layout.BorderPane; - -/** - * A tile that shows a brief bit of information about an account history item. - */ -public abstract class AccountHistoryItemTile extends BorderPane { - public AccountHistoryItemTile(HistoryItem item) { - getStyleClass().add("tile"); - - Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp())); - timestampLabel.getStyleClass().add("small-font"); - setTop(timestampLabel); - } - - public static AccountHistoryItemTile forItem( - HistoryItem item - ) { - if (item instanceof HistoryTextItem t) { - return new AccountHistoryTextTile(t); - } - throw new RuntimeException("Unsupported history item type: " + item.getType()); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java deleted file mode 100644 index 2c7b8a2..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.model.history.HistoryTextItem; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; - -public class AccountHistoryTextTile extends AccountHistoryItemTile { - public AccountHistoryTextTile(HistoryTextItem item) { - super(item); - setCenter(new TextFlow(new Text(item.getDescription()))); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTile.java new file mode 100644 index 0000000..cdfcc37 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTile.java @@ -0,0 +1,20 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.data.util.DateUtil; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; + +import java.time.LocalDateTime; + +public class AccountHistoryTile extends VBox { + public AccountHistoryTile(LocalDateTime timestamp, Node centerContent) { + getStyleClass().add("history-tile"); + + Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(timestamp)); + timestampLabel.getStyleClass().addAll("small-font", "mono-font", "secondary-color-text-fill"); + getChildren().add(timestampLabel); + getChildren().add(centerContent); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryView.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryView.java new file mode 100644 index 0000000..ac7d46e --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryView.java @@ -0,0 +1,146 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.control.TransactionsViewController; +import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.DataSource; +import com.andrewlalis.perfin.data.util.CurrencyUtil; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.model.AccountEntry; +import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.Timestamped; +import com.andrewlalis.perfin.model.history.HistoryTextItem; +import com.andrewlalis.perfin.view.BindingUtil; +import javafx.application.Platform; +import javafx.beans.property.*; +import javafx.geometry.Orientation; +import javafx.scene.Node; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class AccountHistoryView extends ScrollPane { + private LocalDateTime lastTimestamp = null; + private final BooleanProperty canLoadMore = new SimpleBooleanProperty(true); + private final VBox itemsVBox = new VBox(); + private final LongProperty accountIdProperty = new SimpleLongProperty(-1L); + private final IntegerProperty initialItemsToLoadProperty = new SimpleIntegerProperty(10); + + public AccountHistoryView() { + VBox scrollableContentVBox = new VBox(); + scrollableContentVBox.getChildren().add(itemsVBox); + itemsVBox.setMinWidth(0); + + Hyperlink loadMoreLink = new Hyperlink("Load more history"); + loadMoreLink.setOnAction(event -> loadMoreHistory()); + BindingUtil.bindManagedAndVisible(loadMoreLink, canLoadMore); + + scrollableContentVBox.getChildren().add(new BorderPane(loadMoreLink)); + itemsVBox.getStyleClass().addAll("tile-container"); + this.setContent(scrollableContentVBox); + this.setFitToHeight(true); + this.setFitToWidth(true); + this.setHbarPolicy(ScrollBarPolicy.AS_NEEDED); + this.setVbarPolicy(ScrollBarPolicy.AS_NEEDED); + } + + public void loadMoreHistory() { + long accountId = accountIdProperty.get(); + int maxItems = initialItemsToLoadProperty.get(); + DataSource ds = Profile.getCurrent().dataSource(); + ds.mapRepoAsync(AccountRepository.class, repo -> repo.findEventsBefore(accountId, lastTimestamp(), maxItems)) + .thenAccept(entities -> Platform.runLater(() -> addEntitiesToHistory(entities, maxItems))); + } + + public void clear() { + itemsVBox.getChildren().clear(); + canLoadMore.set(true); + lastTimestamp = null; + } + + public void setAccountId(long accountId) { + this.accountIdProperty.set(accountId); + } + + // Property methods + public final IntegerProperty initialItemsToLoadProperty() { + return initialItemsToLoadProperty; + } + + public final int getInitialItemsToLoad() { + return initialItemsToLoadProperty.get(); + } + + public final void setInitialItemsToLoad(int value) { + initialItemsToLoadProperty.set(value); + } + + private LocalDateTime lastTimestamp() { + if (lastTimestamp == null) return DateUtil.nowAsUTC(); + return lastTimestamp; + } + + private Node makeTile(Timestamped entity) { + switch (entity) { + case HistoryTextItem textItem -> { + return new AccountHistoryTile(textItem.getTimestamp(), new TextFlow(new Text(textItem.getDescription()))); + } + case AccountEntry ae -> { + Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId()); + txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId()))); + String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT + ? "credited %s from this account." + : "debited %s to this account."; + String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue())); + TextFlow textFlow = new TextFlow(txLink, new Text(description)); + return new AccountHistoryTile(ae.getTimestamp(), textFlow); + } + case BalanceRecord br -> { + Hyperlink brLink = new Hyperlink("Balance Record #" + br.id); + brLink.setOnAction(event -> router.navigate("balance-record", br)); + return new AccountHistoryTile(br.getTimestamp(), new TextFlow( + brLink, + new Text("added with a value of %s.".formatted(CurrencyUtil.formatMoney(br.getMoneyAmount()))) + )); + } + default -> { + return new AccountHistoryTile(entity.getTimestamp(), new TextFlow(new Text("Unsupported entity: " + entity.getClass().getName()))); + } + } + } + + private void addEntitiesToHistory(List entities, int requestedItems) { + if (!itemsVBox.getChildren().isEmpty()) { + itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL)); + } + itemsVBox.getChildren().addAll(entities.stream() + .map(this::makeTile) + .map(tile -> { + // Use this to scrunch content to the left. + AnchorPane ap = new AnchorPane(tile); + AnchorPane.setLeftAnchor(tile, 0.0); + return ap; + }) + .toList()); + if (entities.size() < requestedItems) { + canLoadMore.set(false); + BorderPane endMarker = new BorderPane(new Label("This is the start of the history.")); + endMarker.getStyleClass().addAll("large-font", "italic-text"); + itemsVBox.getChildren().add(endMarker); + } + if (!entities.isEmpty()) { + lastTimestamp = entities.getLast().getTimestamp(); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java b/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java new file mode 100644 index 0000000..cdaa08f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java @@ -0,0 +1,15 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.model.TransactionCategory; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.shape.Circle; + +public class CategoryLabel extends HBox { + public CategoryLabel(TransactionCategory category) { + Circle colorIndicator = new Circle(8, category.getColor()); + Label label = new Label(category.getName()); + this.getChildren().addAll(colorIndicator, label); + this.getStyleClass().add("std-spacing"); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java index 993fd4b..5eb2c48 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java @@ -1,10 +1,10 @@ package com.andrewlalis.perfin.view.component; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; import com.andrewlalis.perfin.data.TransactionRepository; +import com.andrewlalis.perfin.data.TransactionVendorRepository; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.CreditAndDebitAccounts; -import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Transaction; import javafx.application.Platform; @@ -16,11 +16,10 @@ import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +import javafx.scene.shape.Circle; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; -import java.util.concurrent.CompletableFuture; - import static com.andrewlalis.perfin.PerfinApp.router; /** @@ -35,6 +34,7 @@ public class TransactionTile extends BorderPane { setTop(getHeader(transaction)); setCenter(getBody(transaction)); setBottom(getFooter(transaction)); + setRight(getExtra(transaction)); selected.addListener((observable, oldValue, newValue) -> { if (newValue) { @@ -71,7 +71,10 @@ public class TransactionTile extends BorderPane { VBox bodyVBox = new VBox( propertiesPane ); - getCreditAndDebitAccounts(transaction).thenAccept(accounts -> { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionRepository.class, + repo -> repo.findLinkedAccounts(transaction.id) + ).thenAccept(accounts -> { accounts.ifCredit(acc -> { Hyperlink link = new Hyperlink(acc.getShortName()); link.setOnAction(event -> router.navigate("account", acc)); @@ -99,10 +102,26 @@ public class TransactionTile extends BorderPane { return footerHBox; } - private CompletableFuture getCreditAndDebitAccounts(Transaction transaction) { - return Profile.getCurrent().dataSource().mapRepoAsync( - TransactionRepository.class, - repo -> repo.findLinkedAccounts(transaction.id) - ); + private Node getExtra(Transaction transaction) { + VBox content = new VBox(); + if (transaction.getCategoryId() != null) { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionCategoryRepository.class, + repo -> repo.findById(transaction.getCategoryId()).orElse(null) + ).thenAccept(category -> { + if (category == null) return; + Platform.runLater(() -> content.getChildren().add(new CategoryLabel(category))); + }); + } + if (transaction.getVendorId() != null) { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionVendorRepository.class, + repo -> repo.findById(transaction.getVendorId()).orElse(null) + ).thenAccept(vendor -> { + if (vendor == null) return; + Platform.runLater(() -> content.getChildren().addLast(new Text("@ " + vendor.getName()))); + }); + } + return content; } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/module/RecentTransactionsModule.java b/src/main/java/com/andrewlalis/perfin/view/component/module/RecentTransactionsModule.java index a846c8d..edf065f 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/module/RecentTransactionsModule.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/module/RecentTransactionsModule.java @@ -1,22 +1,21 @@ package com.andrewlalis.perfin.view.component.module; import com.andrewlalis.perfin.control.TransactionsViewController; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Transaction; import com.andrewlalis.perfin.view.BindingUtil; +import com.andrewlalis.perfin.view.component.CategoryLabel; import com.andrewlalis.perfin.view.component.StyledText; import javafx.application.Platform; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; +import javafx.scene.layout.*; import static com.andrewlalis.perfin.PerfinApp.router; @@ -45,6 +44,16 @@ public class RecentTransactionsModule extends DashboardModule { refreshButton )); this.getChildren().add(scrollPane); + + Button viewVendorsButton = new Button("View Vendors"); + viewVendorsButton.setOnAction(event -> router.navigate("vendors")); + Button viewCategoriesButton = new Button("View Categories"); + viewCategoriesButton.setOnAction(event -> router.navigate("categories")); + Button viewTagsButton = new Button("View Tags"); + viewTagsButton.setOnAction(event -> router.navigate("tags")); + HBox footerButtonBox = new HBox(viewVendorsButton, viewCategoriesButton, viewTagsButton); + footerButtonBox.getStyleClass().addAll("std-padding", "std-spacing", "small-font"); + this.getChildren().add(footerButtonBox); } @Override @@ -87,12 +96,22 @@ public class RecentTransactionsModule extends DashboardModule { Label descriptionLabel = new Label(tx.getDescription()); BindingUtil.bindManagedAndVisible(descriptionLabel, descriptionLabel.textProperty().isNotEmpty()); + Label balanceLabel = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(tx.getMoneyAmount())); balanceLabel.getStyleClass().addAll("mono-font"); + VBox rightPanel = new VBox(balanceLabel); + if (tx.getCategoryId() != null) { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionCategoryRepository.class, + repo -> repo.findById(tx.getCategoryId()).orElse(null) + ).thenAccept(category -> { + if (category != null) Platform.runLater(() -> rightPanel.getChildren().add(new CategoryLabel(category))); + }); + } VBox contentBox = new VBox(dateLabel, descriptionLabel, linkedAccountsLabel); borderPane.setCenter(contentBox); - borderPane.setRight(balanceLabel); + borderPane.setRight(rightPanel); return borderPane; } diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index 3005798..89e8c50 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -1,5 +1,6 @@ + @@ -49,11 +50,11 @@