diff --git a/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java b/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java index 0e1caac..9b50356 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java +++ b/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java @@ -13,6 +13,11 @@ public record TimestampRange(LocalDateTime start, LocalDateTime end) { return new TimestampRange(now.minusDays(days), now); } + public static TimestampRange lastNMonths(int months) { + LocalDateTime now = DateUtil.nowAsUTC(); + return new TimestampRange(now.minusMonths(months), now); + } + public static TimestampRange thisMonth() { LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1); LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault()) diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 812eb32..74f94c9 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -36,6 +36,8 @@ public interface TransactionRepository extends Repository, AutoCloseable { long countAllAfter(long transactionId); long countAllByAccounts(Set accountIds); Page findAllByAccounts(Set accountIds, PageRequest pagination); + Optional findEarliest(); + Optional findLatest(); CreditAndDebitAccounts findLinkedAccounts(long transactionId); List findAttachments(long transactionId); List findTags(long transactionId); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index 6650702..55ccb0b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -202,6 +202,26 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction); } + @Override + public Optional findEarliest() { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1", + Collections.emptyList(), + JdbcTransactionRepository::parseTransaction + ); + } + + @Override + public Optional findLatest() { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1", + Collections.emptyList(), + JdbcTransactionRepository::parseTransaction + ); + } + @Override public CreditAndDebitAccounts findLinkedAccounts(long transactionId) { Account creditAccount = DbUtil.findOne( diff --git a/src/main/java/com/andrewlalis/perfin/view/component/module/TotalAssetsGraphModule.java b/src/main/java/com/andrewlalis/perfin/view/component/module/TotalAssetsGraphModule.java index 99f1f49..ceff061 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/module/TotalAssetsGraphModule.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/module/TotalAssetsGraphModule.java @@ -1,19 +1,24 @@ package com.andrewlalis.perfin.view.component.module; +import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.TimestampRange; +import com.andrewlalis.perfin.data.TransactionRepository; +import com.andrewlalis.perfin.data.util.ColorUtil; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.Transaction; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.chart.*; +import javafx.scene.control.ChoiceBox; import javafx.scene.layout.Pane; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneId; +import java.time.*; +import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; /** * A module for visualizing the total asset value in the user's profile over @@ -21,24 +26,68 @@ import java.util.concurrent.CompletableFuture; */ public class TotalAssetsGraphModule extends DashboardModule { private final ObservableList> totalAssetDataPoints = FXCollections.observableArrayList(); + private final ChoiceBox currencyChoiceBox = new ChoiceBox<>(); + private final ChoiceBox timeRangeChoiceBox = new ChoiceBox<>(); + + private static final String PREFERRED_CURRENCY_SETTING = "charts.total-assets.default-currency"; + private static final String PREFERRED_TIME_RANGE_SETTING = "charts.total-assets.default-time-range"; + + private record TimeRangeOption(String name, Supplier rangeSupplier) {} + + private static final TimeRangeOption[] TIME_RANGE_OPTIONS = { + new TimeRangeOption("Last 90 Days", () -> TimestampRange.lastNDays(90)), + new TimeRangeOption("Last 6 Months", () -> TimestampRange.lastNMonths(6)), + new TimeRangeOption("Last 12 Months", () -> TimestampRange.lastNMonths(12)), + new TimeRangeOption("This Year", TimestampRange::thisYear), + new TimeRangeOption("Last 5 Years", () -> TimestampRange.lastNMonths(60)), + new TimeRangeOption("All Time", TimestampRange::unbounded) + }; public TotalAssetsGraphModule(Pane parent) { super(parent); Axis xAxis = new CategoryAxis(); Axis yAxis = new NumberAxis(); + xAxis.setAnimated(false); + yAxis.setAnimated(false); LineChart chart = new LineChart<>(xAxis, yAxis, FXCollections.observableArrayList( new XYChart.Series<>("Total Assets", totalAssetDataPoints) )); chart.setLegendVisible(false); + chart.setAnimated(false); + // Init currency choice box. + currencyChoiceBox.managedProperty().bind(currencyChoiceBox.visibleProperty()); + currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + renderChart(); + } else { + totalAssetDataPoints.clear(); + } + }); + // Init time range choice box. + timeRangeChoiceBox.getItems().addAll( + Arrays.stream(TIME_RANGE_OPTIONS).map(TimeRangeOption::name).toList() + ); + timeRangeChoiceBox.getSelectionModel().select(TIME_RANGE_OPTIONS[0].name()); + timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> renderChart()); + // Add all the elements to the module VBox. this.getChildren().add(new ModuleHeader( - "Total Assets over Time" + "Total Assets over Time", + timeRangeChoiceBox, + currencyChoiceBox )); this.getChildren().add(chart); } @Override public void refreshContents() { + refreshCurrencies(); + String savedTimeRangeLabel = Profile.getCurrent().getSetting(PREFERRED_TIME_RANGE_SETTING).orElse(null); + if (savedTimeRangeLabel != null && Arrays.stream(TIME_RANGE_OPTIONS).anyMatch(o -> o.name().equals(savedTimeRangeLabel))) { + timeRangeChoiceBox.getSelectionModel().select(savedTimeRangeLabel); + } + + // TODO: Extract logic below. totalAssetDataPoints.clear(); String[] dateLabels = new String[12]; double[] values = new double[12]; @@ -60,4 +109,101 @@ public class TotalAssetsGraphModule extends DashboardModule { Platform.runLater(() -> totalAssetDataPoints.addAll(dataPoints)); }); } + + private void renderChart() { + totalAssetDataPoints.clear(); + final Currency currency = currencyChoiceBox.getValue(); + String timeRangeLabel = timeRangeChoiceBox.getValue(); + if (currency == null || timeRangeLabel == null) { + return; + } + + getSelectedTimestampRange().thenAccept(range -> { + Duration rangeDuration = Duration.between(range.start(), range.end()); + boolean useMonths = rangeDuration.dividedBy(Duration.ofDays(1)) > 90; + + + List> dataFutures = new ArrayList<>(); + List labels = new ArrayList<>(); + LocalDateTime currentInterval = range.start(); + while (currentInterval.isBefore(range.end())) { + Instant currentInstant = currentInterval.toInstant(ZoneOffset.UTC); + final String label; + if (useMonths) { + label = currentInterval.format(DateTimeFormatter.ofPattern("MMM yyyy")); + } else { + label = currentInterval.format(DateTimeFormatter.ofPattern("dd MMM yyyy")); + } + labels.add(label); + dataFutures.add(Profile.getCurrent().dataSource().getCombinedAccountBalances(currentInstant) + .thenApply(moneyValues -> moneyValues.stream() + .filter(m -> m.currency().equals(currency)) + .map(m -> m.amount().doubleValue()) + .findFirst().orElse(0.0) + )); + if (useMonths) { + currentInterval = currentInterval.plusMonths(1); + } else { + currentInterval = currentInterval.plusDays(1); + } + } + + // Once all futures are complete, we build the final list of data points + // for the chart, and send them off. + CompletableFuture.allOf(dataFutures.toArray(new CompletableFuture[0])).thenRun(() -> { + List> dataPoints = new ArrayList<>(dataFutures.size()); + for (int i = 0; i < dataFutures.size(); i++) { + dataPoints.add(new XYChart.Data<>(labels.get(i), dataFutures.get(i).join())); + } + Platform.runLater(() -> totalAssetDataPoints.setAll(dataPoints)); + }); + }); + + Profile.getCurrent().setSettingAndSave(PREFERRED_CURRENCY_SETTING, currency.getCurrencyCode()); + Profile.getCurrent().setSettingAndSave(PREFERRED_TIME_RANGE_SETTING, timeRangeLabel); + } + + private CompletableFuture getSelectedTimestampRange() { + String selectedLabel = timeRangeChoiceBox.getValue(); + if (selectedLabel == null || Arrays.stream(TIME_RANGE_OPTIONS).noneMatch(o -> o.name().equals(selectedLabel))) { + return CompletableFuture.completedFuture(TimestampRange.thisYear()); + } + if (selectedLabel.equals("All Time")) { + return Profile.getCurrent().dataSource().mapRepoAsync( + TransactionRepository.class, + repo -> repo.findEarliest().map(Transaction::getTimestamp) + .orElse(LocalDateTime.now(ZoneOffset.UTC).minusYears(1)) + ).thenApply(ts -> new TimestampRange(ts, LocalDateTime.now(ZoneOffset.UTC))); + } + return CompletableFuture.completedFuture(Arrays.stream(TIME_RANGE_OPTIONS) + .filter(o -> o.name().equals(selectedLabel)) + .findFirst() + .map(o -> o.rangeSupplier().get()) + .orElseThrow()); + } + + private void refreshCurrencies() { + Profile.getCurrent().dataSource().mapRepoAsync( + AccountRepository.class, + AccountRepository::findAllUsedCurrencies + ) + .thenAccept(currencies -> { + final List orderedCurrencies = currencies.isEmpty() + ? List.of(Currency.getInstance("USD")) + : currencies.stream() + .sorted(Comparator.comparing(Currency::getCurrencyCode)) + .toList(); + final Currency preferredCurrency = Profile.getCurrent().getSetting(PREFERRED_CURRENCY_SETTING) + .map(Currency::getInstance).orElse(null); + Platform.runLater(() -> { + currencyChoiceBox.getItems().setAll(orderedCurrencies); + if (preferredCurrency != null && currencies.contains(preferredCurrency)) { + currencyChoiceBox.getSelectionModel().select(preferredCurrency); + } else { + currencyChoiceBox.getSelectionModel().selectFirst(); + } + currencyChoiceBox.setVisible(orderedCurrencies.size() > 1); + }); + }); + } }