diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 46d3d98..02aec48 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -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); diff --git a/src/main/java/com/andrewlalis/perfin/control/DashboardController.java b/src/main/java/com/andrewlalis/perfin/control/DashboardController.java index 4c1eb6d..a33667c 100644 --- a/src/main/java/com/andrewlalis/perfin/control/DashboardController.java +++ b/src/main/java/com/andrewlalis/perfin/control/DashboardController.java @@ -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(); + } } } diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index ea20479..a37a964 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -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); diff --git a/src/main/java/com/andrewlalis/perfin/data/AnalyticsRepository.java b/src/main/java/com/andrewlalis/perfin/data/AnalyticsRepository.java new file mode 100644 index 0000000..6b92907 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/AnalyticsRepository.java @@ -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> getSpendByCategory(TimestampRange range, Currency currency); + List> getSpendByRootCategory(TimestampRange range, Currency currency); + List> getIncomeByCategory(TimestampRange range, Currency currency); + List> getIncomeByRootCategory(TimestampRange range, Currency currency); + List> getSpendByVendor(TimestampRange range, Currency currency); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index bd2244f..1ef61f0 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -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) repoSuppliers.get(type); } diff --git a/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java b/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java new file mode 100644 index 0000000..92482a8 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TimestampRange.java @@ -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); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java index a996349..1862043 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -11,6 +11,7 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable Optional findByName(String name); List findAllBaseCategories(); List 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); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAnalyticsRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAnalyticsRepository.java new file mode 100644 index 0000000..0bb65c4 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAnalyticsRepository.java @@ -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> getSpendByCategory(TimestampRange range, Currency currency) { + return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.CREDIT); + } + + @Override + public List> getSpendByRootCategory(TimestampRange range, Currency currency) { + return groupByRootCategory(getSpendByCategory(range, currency)); + } + + @Override + public List> getIncomeByCategory(TimestampRange range, Currency currency) { + return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.DEBIT); + } + + @Override + public List> getIncomeByRootCategory(TimestampRange range, Currency currency) { + return groupByRootCategory(getIncomeByCategory(range, currency)); + } + + @Override + public List> 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> 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> groupByRootCategory(List> spendByCategory) { + List> result = new ArrayList<>(); + Map 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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index 9f7b172..1469d31 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -68,4 +68,9 @@ public class JdbcDataSource implements DataSource { public HistoryRepository getHistoryRepository() { return new JdbcHistoryRepository(getConnection()); } + + @Override + public AnalyticsRepository getAnalyticsRepository() { + return new JdbcAnalyticsRepository(getConnection()); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java index 7dcd072..91eb30d 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -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); } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index de4647b..75ab590 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -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>> 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 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) { diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileBackups.java b/src/main/java/com/andrewlalis/perfin/model/ProfileBackups.java new file mode 100644 index 0000000..9381a3d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileBackups.java @@ -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")); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java index 534c3f0..90cb815 100644 --- a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -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 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)); diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java index 2dfd29f..7d96e7d 100644 --- a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java @@ -16,4 +16,9 @@ public class TransactionTag extends IdEntity { public String getName() { return name; } + + @Override + public String toString() { + return name; + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/module/PieChartModule.java b/src/main/java/com/andrewlalis/perfin/view/component/module/PieChartModule.java new file mode 100644 index 0000000..babc4cb --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/module/PieChartModule.java @@ -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 chartData = FXCollections.observableArrayList(); + protected final List dataColors = new ArrayList<>(); + private final ChoiceBox 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 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> getChartData(Currency currency); +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/module/SpendingCategoryChartModule.java b/src/main/java/com/andrewlalis/perfin/view/component/module/SpendingCategoryChartModule.java index a4d5d23..7a1b1bc 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/module/SpendingCategoryChartModule.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/module/SpendingCategoryChartModule.java @@ -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> 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(); + }); } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/module/VendorSpendChartModule.java b/src/main/java/com/andrewlalis/perfin/view/component/module/VendorSpendChartModule.java new file mode 100644 index 0000000..18b9e28 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/module/VendorSpendChartModule.java @@ -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> 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(); + }); + } +}