Cleaned up account history, improved transaction tiles in dashboard and transactions list, and fixed small bug in balance record validation.

This commit is contained in:
Andrew Lalis 2024-02-06 09:16:12 -05:00
parent 7d50b12a4f
commit 970ca46ef6
26 changed files with 405 additions and 144 deletions

View File

@ -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<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
if (items.size() < historyLoadSize) {
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
} else {
loadHistoryFrom = items.getLast().getTimestamp();
}
List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
});
}
}

View File

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

View File

@ -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<AccountEntry> findById(long id);
List<AccountEntry> findAllByAccountId(long accountId);
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
}

View File

@ -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<Currency> findAllUsedCurrencies();
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
}

View File

@ -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<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId);
Optional<BalanceRecord> findById(long id);
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
List<Attachment> findAttachments(long recordId);

View File

@ -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<HistoryItem> getItem(long id);
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {

View File

@ -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<AccountEntry> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM account_entry WHERE id = ?",
id,
JdbcAccountEntryRepository::parse
);
}
@Override

View File

@ -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<Timestamped> 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<Timestamped> 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(

View File

@ -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<BalanceRecord> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM balance_record WHERE id = ?",
id,
JdbcBalanceRecordRepository::parse
);
}
@Override
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
return DbUtil.findOne(

View File

@ -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<String> 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;");

View File

@ -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<HistoryItem> 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 = ?",

View File

@ -30,7 +30,7 @@ import java.util.Currency;
* all those extra accounts would be a burden to casual users.
* </p>
*/
public class AccountEntry extends IdEntity {
public class AccountEntry extends IdEntity implements Timestamped {
public enum Type {
CREDIT,
DEBIT

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.AccountHistoryView?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
@ -49,11 +50,11 @@
<VBox styleClass="std-padding,std-spacing">
<Label text="Actions" styleClass="bold-text"/>
<VBox fx:id="actionsVBox" styleClass="std-spacing">
<Button text="Edit" onAction="#goToEditPage"/>
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
<Button text="Archive" onAction="#archiveAccount"/>
<Button text="Delete" onAction="#deleteAccount"/>
<Button text="Unarchive" onAction="#unarchiveAccount"/>
<Button text="Edit" onAction="#goToEditPage" maxWidth="Infinity"/>
<Button text="Record Balance" onAction="#goToCreateBalanceRecord" maxWidth="Infinity"/>
<Button text="Archive" onAction="#archiveAccount" maxWidth="Infinity"/>
<Button text="Delete" onAction="#deleteAccount" maxWidth="Infinity"/>
<Button text="Unarchive" onAction="#unarchiveAccount" maxWidth="Infinity"/>
</VBox>
</VBox>
</right>
@ -62,20 +63,7 @@
<!-- Account history -->
<VBox VBox.vgrow="ALWAYS">
<Label text="History" styleClass="bold-text,std-padding"/>
<VBox>
<ScrollPane styleClass="tile-container-scroll">
<VBox fx:id="historyItemsVBox" styleClass="tile-container"/>
</ScrollPane>
<AnchorPane>
<Button
fx:id="loadMoreHistoryButton"
text="Load more history"
onAction="#loadMoreHistory"
AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
/>
</AnchorPane>
</VBox>
<AccountHistoryView fx:id="accountHistory" initialItemsToLoad="10"/>
</VBox>
</VBox>
</center>

View File

@ -110,6 +110,12 @@ Text {
-fx-background-color: -fx-theme-background-3;
}
.history-tile {
-fx-background-color: -fx-theme-background-2;
-fx-padding: 10px;
-fx-background-radius: 15px;
}
/* Validation styling. */
.validation-field-invalid {
-fx-border-color: -fx-theme-negative;