Compare commits
No commits in common. "d360de5d6ff792522236b70aafafadcc5e351fc6" and "b6fef8d42fc16a30fee10ec844bb386197afdc0a" have entirely different histories.
d360de5d6f
...
b6fef8d42f
|
@ -13,11 +13,6 @@ public record TimestampRange(LocalDateTime start, LocalDateTime end) {
|
||||||
return new TimestampRange(now.minusDays(days), now);
|
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() {
|
public static TimestampRange thisMonth() {
|
||||||
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
||||||
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
||||||
|
|
|
@ -36,8 +36,6 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
long countAllAfter(long transactionId);
|
long countAllAfter(long transactionId);
|
||||||
long countAllByAccounts(Set<Long> accountIds);
|
long countAllByAccounts(Set<Long> accountIds);
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
Optional<Transaction> findEarliest();
|
|
||||||
Optional<Transaction> findLatest();
|
|
||||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||||
List<Attachment> findAttachments(long transactionId);
|
List<Attachment> findAttachments(long transactionId);
|
||||||
List<String> findTags(long transactionId);
|
List<String> findTags(long transactionId);
|
||||||
|
|
|
@ -202,26 +202,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Transaction> findEarliest() {
|
|
||||||
return DbUtil.findOne(
|
|
||||||
conn,
|
|
||||||
"SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1",
|
|
||||||
Collections.emptyList(),
|
|
||||||
JdbcTransactionRepository::parseTransaction
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Transaction> findLatest() {
|
|
||||||
return DbUtil.findOne(
|
|
||||||
conn,
|
|
||||||
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1",
|
|
||||||
Collections.emptyList(),
|
|
||||||
JdbcTransactionRepository::parseTransaction
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
||||||
Account creditAccount = DbUtil.findOne(
|
Account creditAccount = DbUtil.findOne(
|
||||||
|
|
|
@ -1,25 +1,19 @@
|
||||||
package com.andrewlalis.perfin.view.component.module;
|
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.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.scene.chart.*;
|
import javafx.scene.chart.*;
|
||||||
import javafx.scene.control.ChoiceBox;
|
|
||||||
import javafx.scene.layout.Pane;
|
import javafx.scene.layout.Pane;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A module for visualizing the total asset value in the user's profile over
|
* A module for visualizing the total asset value in the user's profile over
|
||||||
|
@ -27,162 +21,43 @@ import java.util.function.Supplier;
|
||||||
*/
|
*/
|
||||||
public class TotalAssetsGraphModule extends DashboardModule {
|
public class TotalAssetsGraphModule extends DashboardModule {
|
||||||
private final ObservableList<XYChart.Data<String, Number>> totalAssetDataPoints = FXCollections.observableArrayList();
|
private final ObservableList<XYChart.Data<String, Number>> totalAssetDataPoints = FXCollections.observableArrayList();
|
||||||
private final ChoiceBox<Currency> currencyChoiceBox = new ChoiceBox<>();
|
|
||||||
private final ChoiceBox<String> 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<TimestampRange> 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) {
|
public TotalAssetsGraphModule(Pane parent) {
|
||||||
super(parent);
|
super(parent);
|
||||||
Axis<String> xAxis = new CategoryAxis();
|
Axis<String> xAxis = new CategoryAxis();
|
||||||
Axis<Number> yAxis = new NumberAxis();
|
Axis<Number> yAxis = new NumberAxis();
|
||||||
xAxis.setAnimated(false);
|
|
||||||
yAxis.setAnimated(false);
|
|
||||||
|
|
||||||
LineChart<String, Number> chart = new LineChart<>(xAxis, yAxis, FXCollections.observableArrayList(
|
LineChart<String, Number> chart = new LineChart<>(xAxis, yAxis, FXCollections.observableArrayList(
|
||||||
new XYChart.Series<>("Total Assets", totalAssetDataPoints)
|
new XYChart.Series<>("Total Assets", totalAssetDataPoints)
|
||||||
));
|
));
|
||||||
chart.setLegendVisible(false);
|
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(
|
this.getChildren().add(new ModuleHeader(
|
||||||
"Total Assets over Time",
|
"Total Assets over Time"
|
||||||
timeRangeChoiceBox,
|
|
||||||
currencyChoiceBox
|
|
||||||
));
|
));
|
||||||
this.getChildren().add(chart);
|
this.getChildren().add(chart);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void refreshContents() {
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void renderChart() {
|
|
||||||
totalAssetDataPoints.clear();
|
totalAssetDataPoints.clear();
|
||||||
final Currency currency = currencyChoiceBox.getValue();
|
String[] dateLabels = new String[12];
|
||||||
String timeRangeLabel = timeRangeChoiceBox.getValue();
|
double[] values = new double[12];
|
||||||
if (currency == null || timeRangeLabel == null) {
|
CompletableFuture<?>[] futures = new CompletableFuture[12];
|
||||||
return;
|
for (int i = 0; i < 12; i++) {
|
||||||
|
final int idx = i;
|
||||||
|
Instant timestamp = Instant.now().minus((12 - i - 1) * 30, ChronoUnit.DAYS);
|
||||||
|
dateLabels[i] = LocalDate.from(timestamp.atZone(ZoneId.systemDefault())).toString();
|
||||||
|
futures[i] = Profile.getCurrent().dataSource().getCombinedAccountBalances(timestamp)
|
||||||
|
.thenAccept(moneyValues -> {
|
||||||
|
values[idx] = moneyValues.getFirst().amount().doubleValue();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
CompletableFuture.allOf(futures).thenRun(() -> {
|
||||||
getSelectedTimestampRange().thenAccept(range -> {
|
List<XYChart.Data<String, Number>> dataPoints = new ArrayList<>(12);
|
||||||
Duration rangeDuration = Duration.between(range.start(), range.end());
|
for (int i = 0; i < 12; i++) {
|
||||||
boolean useMonths = rangeDuration.dividedBy(Duration.ofDays(1)) > 365;
|
dataPoints.add(new XYChart.Data<>(dateLabels[i], values[i]));
|
||||||
|
|
||||||
|
|
||||||
List<CompletableFuture<Number>> dataFutures = new ArrayList<>();
|
|
||||||
List<String> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Platform.runLater(() -> totalAssetDataPoints.addAll(dataPoints));
|
||||||
// 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<XYChart.Data<String, Number>> 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<TimestampRange> 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<Currency> 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue