Improve backups, and add pie chart modules for vendor and category.

This commit is contained in:
Andrew Lalis 2024-02-05 11:27:20 -05:00
parent f4d8a4803b
commit 54f6612048
17 changed files with 476 additions and 85 deletions

View File

@ -114,6 +114,7 @@ public class PerfinApp extends Application {
if (Files.notExists(APP_DIR)) {
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
Files.createDirectory(APP_DIR);
Files.createDirectory(Profile.getProfilesDir());
} else if (Files.exists(APP_DIR) && Files.isRegularFile(APP_DIR)) {
msgConsumer.accept(APP_DIR + " is a file, when it should be a directory. Deleting it and creating new directory.");
Files.delete(APP_DIR);

View File

@ -1,9 +1,7 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.view.component.module.AccountsModule;
import com.andrewlalis.perfin.view.component.module.DashboardModule;
import com.andrewlalis.perfin.view.component.module.RecentTransactionsModule;
import com.andrewlalis.perfin.view.component.module.*;
import javafx.fxml.FXML;
import javafx.geometry.Bounds;
import javafx.scene.control.ScrollPane;
@ -13,27 +11,32 @@ public class DashboardController implements RouteSelectionListener {
@FXML public ScrollPane modulesScrollPane;
@FXML public FlowPane modulesFlowPane;
private DashboardModule accountsModule;
private DashboardModule transactionsModule;
@FXML public void initialize() {
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
modulesFlowPane.minWidthProperty().bind(viewportWidth);
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
accountsModule = new AccountsModule(modulesFlowPane);
var accountsModule = new AccountsModule(modulesFlowPane);
accountsModule.columnsProperty.set(2);
transactionsModule = new RecentTransactionsModule(modulesFlowPane);
var transactionsModule = new RecentTransactionsModule(modulesFlowPane);
transactionsModule.columnsProperty.set(2);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule);
var m3 = new SpendingCategoryChartModule(modulesFlowPane);
m3.columnsProperty.set(2);
var m4 = new VendorSpendChartModule(modulesFlowPane);
m4.columnsProperty.set(2);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4);
}
@Override
public void onRouteSelected(Object context) {
accountsModule.refreshContents();
transactionsModule.refreshContents();
for (var child : modulesFlowPane.getChildren()) {
DashboardModule module = (DashboardModule) child;
module.refreshContents();
}
}
}

View File

@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.ProfileBackups;
import com.andrewlalis.perfin.model.ProfileLoader;
import com.andrewlalis.perfin.view.ProfilesStage;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
@ -123,7 +124,7 @@ public class ProfilesViewController {
private void makeBackup(String name) {
try {
Path backupFile = ProfileLoader.makeBackup(name);
Path backupFile = ProfileBackups.makeBackup(name);
Popups.message(profilesVBox, "A new backup was created at " + backupFile.toAbsolutePath());
} catch (IOException e) {
Popups.error(profilesVBox, e);

View File

@ -0,0 +1,17 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
public interface AnalyticsRepository extends Repository, AutoCloseable {
List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency);
}

View File

@ -35,6 +35,8 @@ public interface DataSource {
AttachmentRepository getAttachmentRepository();
HistoryRepository getHistoryRepository();
AnalyticsRepository getAnalyticsRepository();
// Repository helper methods:
@SuppressWarnings("unchecked")
@ -86,7 +88,8 @@ public interface DataSource {
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
AttachmentRepository.class, this::getAttachmentRepository,
HistoryRepository.class, this::getHistoryRepository
HistoryRepository.class, this::getHistoryRepository,
AnalyticsRepository.class, this::getAnalyticsRepository
);
return (Supplier<R>) repoSuppliers.get(type);
}

View File

@ -0,0 +1,18 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.DateUtil;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public record TimestampRange(LocalDateTime start, LocalDateTime end) {
public static TimestampRange nDaysTillNow(int days) {
LocalDateTime now = DateUtil.nowAsUTC();
return new TimestampRange(now.minusDays(days), now);
}
public static TimestampRange unbounded() {
LocalDateTime now = DateUtil.nowAsUTC();
return new TimestampRange(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), now);
}
}

View File

@ -11,6 +11,7 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable
Optional<TransactionCategory> findByName(String name);
List<TransactionCategory> findAllBaseCategories();
List<TransactionCategory> findAll();
TransactionCategory findRoot(long categoryId);
long insert(long parentId, String name, Color color);
long insert(String name, Color color);
void update(long id, String name, Color color);

View File

@ -0,0 +1,123 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.scene.paint.Color;
import java.math.BigDecimal;
import java.sql.Connection;
import java.util.*;
public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepository {
@Override
public List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency) {
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.CREDIT);
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency) {
return groupByRootCategory(getSpendByCategory(range, currency));
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency) {
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.DEBIT);
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency) {
return groupByRootCategory(getIncomeByCategory(range, currency));
}
@Override
public List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
tv.id, tv.name, tv.description
FROM transaction
LEFT JOIN transaction_vendor tv ON tv.id = transaction.vendor_id
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
WHERE transaction.currency = ? AND ae.type = 'CREDIT' AND transaction.timestamp >= ? AND transaction.timestamp <= ?
GROUP BY tv.id
ORDER BY total DESC""",
List.of(currency.getCurrencyCode(), range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
long vendorId = rs.getLong(2);
if (rs.wasNull()) return new Pair<>(null, total);
String name = rs.getString(3);
String description = rs.getString(4);
return new Pair<>(new TransactionVendor(vendorId, name, description), total);
}
);
}
@Override
public void close() throws Exception {
conn.close();
}
private List<Pair<TransactionCategory, BigDecimal>> getTransactionAmountByCategoryAndType(TimestampRange range, Currency currency, AccountEntry.Type type) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
tc.id, tc.parent_id, tc.name, tc.color
FROM transaction
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
WHERE transaction.currency = ? AND ae.type = ? AND transaction.timestamp >= ? AND transaction.timestamp <= ?
GROUP BY tc.id
ORDER BY total DESC;""",
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
TransactionCategory category = null;
long categoryId = rs.getLong(2);
if (!rs.wasNull()) {
Long parentId = rs.getLong(3);
if (rs.wasNull()) parentId = null;
String name = rs.getString(4);
Color color = Color.valueOf("#" + rs.getString(5));
category = new TransactionCategory(categoryId, parentId, name, color);
}
return new Pair<>(category, total);
}
);
}
private List<Pair<TransactionCategory, BigDecimal>> groupByRootCategory(List<Pair<TransactionCategory, BigDecimal>> spendByCategory) {
List<Pair<TransactionCategory, BigDecimal>> result = new ArrayList<>();
Map<TransactionCategory, BigDecimal> rootCategorySpend = new HashMap<>();
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
BigDecimal uncategorizedSpend = BigDecimal.ZERO;
for (var spend : spendByCategory) {
if (spend.first() == null) {
uncategorizedSpend = uncategorizedSpend.add(spend.second());
} else {
TransactionCategory rootCategory = categoryRepo.findRoot(spend.first().id);
if (rootCategory != null) {
BigDecimal categoryTotal = rootCategorySpend.getOrDefault(rootCategory, BigDecimal.ZERO);
rootCategorySpend.put(rootCategory, categoryTotal.add(spend.second()));
}
}
}
for (var entry : rootCategorySpend.entrySet()) {
result.add(new Pair<>(entry.getKey(), entry.getValue()));
}
if (uncategorizedSpend.compareTo(BigDecimal.ZERO) > 0) {
result.add(new Pair<>(null, uncategorizedSpend));
}
result.sort((p1, p2) -> p2.second().compareTo(p1.second()));
return result;
}
}

View File

@ -68,4 +68,9 @@ public class JdbcDataSource implements DataSource {
public HistoryRepository getHistoryRepository() {
return new JdbcHistoryRepository(getConnection());
}
@Override
public AnalyticsRepository getAnalyticsRepository() {
return new JdbcAnalyticsRepository(getConnection());
}
}

View File

@ -52,6 +52,13 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
);
}
@Override
public TransactionCategory findRoot(long categoryId) {
TransactionCategory category = findById(categoryId).orElse(null);
if (category == null || category.getParentId() == null) return category;
return findRoot(category.getParentId());
}
@Override
public long insert(long parentId, String name, Color color) {
return DbUtil.insertOne(
@ -132,11 +139,11 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
}
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
return new TransactionCategory(
rs.getLong("id"),
rs.getObject("parent_id", Long.class),
rs.getString("name"),
Color.valueOf("#" + rs.getString("color"))
);
long id = rs.getLong("id");
Long parentId = rs.getLong("parent_id");
if (rs.wasNull()) parentId = null;
String name = rs.getString("name");
Color color = Color.valueOf("#" + rs.getString("color"));
return new TransactionCategory(id, parentId, name, color);
}
}

View File

@ -5,11 +5,11 @@ import com.andrewlalis.perfin.data.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import java.util.*;
import java.util.function.Consumer;
/**
@ -39,13 +39,32 @@ public record Profile(String name, Properties settings, DataSource dataSource) {
private static Profile current;
private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
public void setSettingAndSave(String settingName, String value) {
String previous = settings.getProperty(settingName);
if (Objects.equals(previous, value)) return; // Value is already set.
settings.setProperty(settingName, value);
try (var out = Files.newOutputStream(getSettingsFile(name))) {
settings.store(out, null);
} catch (IOException e) {
log.error("Failed to save settings.", e);
}
}
public Optional<String> getSetting(String settingName) {
return Optional.ofNullable(settings.getProperty(settingName));
}
@Override
public String toString() {
return name;
}
public static Path getProfilesDir() {
return PerfinApp.APP_DIR.resolve("profiles");
}
public static Path getDir(String name) {
return PerfinApp.APP_DIR.resolve(name);
return getProfilesDir().resolve(name);
}
public static Path getContentDir(String name) {

View File

@ -0,0 +1,84 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Helper class with static methods for managing backups of profiles.
*/
public class ProfileBackups {
private static final Logger log = LoggerFactory.getLogger(ProfileBackups.class);
public static Path getBackupDir(String profileName) {
return PerfinApp.APP_DIR.resolve("backups").resolve(profileName);
}
public static Path makeBackup(String name) throws IOException {
log.info("Making backup of profile \"{}\".", name);
final Path profileDir = Profile.getDir(name);
LocalDateTime now = LocalDateTime.now();
Files.createDirectories(getBackupDir(name));
Path backupFile = getBackupDir(name).resolve(String.format(
"%04d-%02d-%02d_%02d-%02d-%02d.zip",
now.getYear(), now.getMonthValue(), now.getDayOfMonth(),
now.getHour(), now.getMinute(), now.getSecond()
));
try (var out = new ZipOutputStream(Files.newOutputStream(backupFile))) {
Files.walkFileTree(profileDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Path relativeFile = profileDir.relativize(file);
out.putNextEntry(new ZipEntry(relativeFile.toString()));
byte[] bytes = Files.readAllBytes(file);
out.write(bytes, 0, bytes.length);
out.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
return backupFile;
}
public static LocalDateTime getLastBackupTimestamp(String name) {
try (var files = Files.list(getBackupDir(name))) {
return files.map(ProfileBackups::getTimestampFromBackup)
.max(LocalDateTime::compareTo)
.orElse(null);
} catch (IOException e) {
log.error("Failed to list files in profile " + name, e);
return null;
}
}
public static void cleanOldBackups(String name) {
final LocalDateTime cutoff = LocalDateTime.now().minusDays(30);
try (var files = Files.list(getBackupDir(name))) {
var filesToDelete = files.filter(path -> {
LocalDateTime timestamp = getTimestampFromBackup(path);
return timestamp.isBefore(cutoff);
}).toList();
for (var file : filesToDelete) {
Files.delete(file);
}
} catch (IOException e) {
log.error("Failed to cleanup backups.", e);
}
}
private static LocalDateTime getTimestampFromBackup(Path backupFile) {
String text = backupFile.getFileName().toString().substring(0, "0000-00-00_00-00-00".length());
return LocalDateTime.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
}
}

View File

@ -2,6 +2,7 @@ package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migrations;
@ -11,18 +12,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
@ -78,21 +72,21 @@ public class ProfileLoader {
}
// Check for a recent backup and make one if not present.
LocalDateTime lastBackup = getLastBackupTimestamp(name);
if (lastBackup == null || lastBackup.isBefore(LocalDateTime.now().minusDays(5))) {
try {
makeBackup(name);
} catch (IOException e) {
log.error("Failed to create backup for profile " + name + ".", e);
}
try {
ProfileBackups.makeBackup(name);
ProfileBackups.cleanOldBackups(name);
} catch (IOException e) {
log.error("Failed to create backup for profile " + name + ".", e);
}
return new Profile(name, settings, dataSourceFactory.getDataSource(name));
DataSource dataSource = dataSourceFactory.getDataSource(name);
return new Profile(name, settings, dataSource);
}
public static List<String> getAvailableProfiles() {
try (var files = Files.list(PerfinApp.APP_DIR)) {
try (var files = Files.list(Profile.getProfilesDir())) {
return files.filter(Files::isDirectory)
.filter(p -> !p.getFileName().toString().startsWith("."))
.map(path -> path.getFileName().toString())
.sorted().toList();
} catch (IOException e) {
@ -123,47 +117,6 @@ public class ProfileLoader {
}
}
public static LocalDateTime getLastBackupTimestamp(String name) {
try (var files = Files.list(Profile.getDir(name))) {
return files.filter(p -> p.getFileName().toString().startsWith("backup_"))
.map(p -> p.getFileName().toString().substring("backup_".length(), "backup_0000-00-00_00-00-00".length()))
.map(s -> LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")))
.max(LocalDateTime::compareTo)
.orElse(null);
} catch (IOException e) {
log.error("Failed to list files in profile " + name, e);
return null;
}
}
public static Path makeBackup(String name) throws IOException {
log.info("Making backup of profile \"{}\".", name);
final Path profileDir = Profile.getDir(name);
LocalDateTime now = LocalDateTime.now();
Path backupFile = profileDir.resolve(String.format(
"backup_%04d-%02d-%02d_%02d-%02d-%02d.zip",
now.getYear(), now.getMonthValue(), now.getDayOfMonth(),
now.getHour(), now.getMinute(), now.getSecond()
));
try (var out = new ZipOutputStream(Files.newOutputStream(backupFile))) {
Files.walkFileTree(profileDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Path relativeFile = profileDir.relativize(file);
if (relativeFile.toString().startsWith("backup_") || relativeFile.toString().equalsIgnoreCase("database.trace.db")) {
return FileVisitResult.CONTINUE;
}
out.putNextEntry(new ZipEntry(relativeFile.toString()));
byte[] bytes = Files.readAllBytes(file);
out.write(bytes, 0, bytes.length);
out.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
return backupFile;
}
@Deprecated
private static void initProfileDir(String name) throws IOException {
Files.createDirectory(Profile.getDir(name));

View File

@ -16,4 +16,9 @@ public class TransactionTag extends IdEntity {
public String getName() {
return name;
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,88 @@
package com.andrewlalis.perfin.view.component.module;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.ColorUtil;
import com.andrewlalis.perfin.model.Profile;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.chart.PieChart;
import javafx.scene.control.ChoiceBox;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* An abstract dashboard module for displaying a pie chart of data based on a
* selected currency context.
*/
public abstract class PieChartModule extends DashboardModule {
private final ObservableList<PieChart.Data> chartData = FXCollections.observableArrayList();
protected final List<Color> dataColors = new ArrayList<>();
private final ChoiceBox<Currency> currencyChoiceBox = new ChoiceBox<>();
private final String preferredCurrencySetting;
public PieChartModule(Pane parent, String title, String preferredCurrencySetting) {
super(parent);
this.preferredCurrencySetting = preferredCurrencySetting;
PieChart chart = new PieChart(chartData);
chart.setLegendVisible(false);
this.getChildren().add(new ModuleHeader(
title,
currencyChoiceBox
));
this.getChildren().add(chart);
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
getChartData(newValue).thenAccept(data -> Platform.runLater(() -> {
chartData.setAll(data);
if (!dataColors.isEmpty()) {
for (int i = 0; i < dataColors.size(); i++) {
if (i >= data.size()) break;
data.get(i).getNode().setStyle("-fx-pie-color: #" + ColorUtil.toHex(dataColors.get(i)));
}
}
}));
Profile.getCurrent().setSettingAndSave(preferredCurrencySetting, newValue.getCurrencyCode());
} else {
chartData.clear();
}
});
}
@Override
public void refreshContents() {
refreshCurrencies();
}
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(preferredCurrencySetting)
.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();
}
});
});
}
protected abstract CompletableFuture<List<PieChart.Data>> getChartData(Currency currency);
}

View File

@ -1,16 +1,42 @@
package com.andrewlalis.perfin.view.component.module;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.chart.PieChart;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
public class SpendingCategoryChartModule extends DashboardModule {
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class SpendingCategoryChartModule extends PieChartModule {
public SpendingCategoryChartModule(Pane parent) {
super(parent);
PieChart chart = new PieChart();
super(parent, "Spending by Category", "charts.category-spend.default-currency");
}
@Override
public void refreshContents() {
protected CompletableFuture<List<PieChart.Data>> getChartData(Currency currency) {
return Profile.getCurrent().dataSource().mapRepoAsync(AnalyticsRepository.class, repo -> {
var data = repo.getSpendByRootCategory(TimestampRange.unbounded(), currency);
dataColors.clear();
return data.stream()
.map(pair -> {
TransactionCategory category = pair.first();
BigDecimal amount = pair.second();
String label = category == null ? "Uncategorized" : category.getName();
label += ": " + CurrencyUtil.formatMoney(new MoneyValue(amount, currency));
var datum = new PieChart.Data(label, amount.doubleValue());
Color color = category == null ? Color.GRAY : category.getColor();
dataColors.add(color);
return datum;
})
.toList();
});
}
}

View File

@ -0,0 +1,37 @@
package com.andrewlalis.perfin.view.component.module;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.scene.chart.PieChart;
import javafx.scene.layout.Pane;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class VendorSpendChartModule extends PieChartModule {
public VendorSpendChartModule(Pane parent) {
super(parent, "Spending by Vendor", "charts.vendor-spend.default-currency");
}
@Override
protected CompletableFuture<List<PieChart.Data>> getChartData(Currency currency) {
return Profile.getCurrent().dataSource().mapRepoAsync(AnalyticsRepository.class, repo -> {
var data = repo.getSpendByVendor(TimestampRange.unbounded(), currency);
return data.stream()
.map(pair -> {
TransactionVendor vendor = pair.first();
BigDecimal amount = pair.second();
String label = vendor == null ? "Uncategorized" : vendor.getName();
label += ": " + CurrencyUtil.formatMoney(new MoneyValue(amount, currency));
return new PieChart.Data(label, amount.doubleValue());
})
.toList();
});
}
}