Improve backups, and add pie chart modules for vendor and category.
This commit is contained in:
parent
f4d8a4803b
commit
54f6612048
|
@ -114,6 +114,7 @@ public class PerfinApp extends Application {
|
||||||
if (Files.notExists(APP_DIR)) {
|
if (Files.notExists(APP_DIR)) {
|
||||||
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
|
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
|
||||||
Files.createDirectory(APP_DIR);
|
Files.createDirectory(APP_DIR);
|
||||||
|
Files.createDirectory(Profile.getProfilesDir());
|
||||||
} else if (Files.exists(APP_DIR) && Files.isRegularFile(APP_DIR)) {
|
} 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.");
|
msgConsumer.accept(APP_DIR + " is a file, when it should be a directory. Deleting it and creating new directory.");
|
||||||
Files.delete(APP_DIR);
|
Files.delete(APP_DIR);
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.view.component.module.AccountsModule;
|
import com.andrewlalis.perfin.view.component.module.*;
|
||||||
import com.andrewlalis.perfin.view.component.module.DashboardModule;
|
|
||||||
import com.andrewlalis.perfin.view.component.module.RecentTransactionsModule;
|
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.geometry.Bounds;
|
import javafx.geometry.Bounds;
|
||||||
import javafx.scene.control.ScrollPane;
|
import javafx.scene.control.ScrollPane;
|
||||||
|
@ -13,27 +11,32 @@ public class DashboardController implements RouteSelectionListener {
|
||||||
@FXML public ScrollPane modulesScrollPane;
|
@FXML public ScrollPane modulesScrollPane;
|
||||||
@FXML public FlowPane modulesFlowPane;
|
@FXML public FlowPane modulesFlowPane;
|
||||||
|
|
||||||
private DashboardModule accountsModule;
|
|
||||||
private DashboardModule transactionsModule;
|
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
|
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
|
||||||
modulesFlowPane.minWidthProperty().bind(viewportWidth);
|
modulesFlowPane.minWidthProperty().bind(viewportWidth);
|
||||||
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
|
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
|
||||||
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
|
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
|
||||||
|
|
||||||
accountsModule = new AccountsModule(modulesFlowPane);
|
var accountsModule = new AccountsModule(modulesFlowPane);
|
||||||
accountsModule.columnsProperty.set(2);
|
accountsModule.columnsProperty.set(2);
|
||||||
|
|
||||||
transactionsModule = new RecentTransactionsModule(modulesFlowPane);
|
var transactionsModule = new RecentTransactionsModule(modulesFlowPane);
|
||||||
transactionsModule.columnsProperty.set(2);
|
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
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
accountsModule.refreshContents();
|
for (var child : modulesFlowPane.getChildren()) {
|
||||||
transactionsModule.refreshContents();
|
DashboardModule module = (DashboardModule) child;
|
||||||
|
module.refreshContents();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.ProfileBackups;
|
||||||
import com.andrewlalis.perfin.model.ProfileLoader;
|
import com.andrewlalis.perfin.model.ProfileLoader;
|
||||||
import com.andrewlalis.perfin.view.ProfilesStage;
|
import com.andrewlalis.perfin.view.ProfilesStage;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
@ -123,7 +124,7 @@ public class ProfilesViewController {
|
||||||
|
|
||||||
private void makeBackup(String name) {
|
private void makeBackup(String name) {
|
||||||
try {
|
try {
|
||||||
Path backupFile = ProfileLoader.makeBackup(name);
|
Path backupFile = ProfileBackups.makeBackup(name);
|
||||||
Popups.message(profilesVBox, "A new backup was created at " + backupFile.toAbsolutePath());
|
Popups.message(profilesVBox, "A new backup was created at " + backupFile.toAbsolutePath());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Popups.error(profilesVBox, e);
|
Popups.error(profilesVBox, e);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -35,6 +35,8 @@ public interface DataSource {
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
HistoryRepository getHistoryRepository();
|
HistoryRepository getHistoryRepository();
|
||||||
|
|
||||||
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
// Repository helper methods:
|
// Repository helper methods:
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -86,7 +88,8 @@ public interface DataSource {
|
||||||
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||||
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||||
AttachmentRepository.class, this::getAttachmentRepository,
|
AttachmentRepository.class, this::getAttachmentRepository,
|
||||||
HistoryRepository.class, this::getHistoryRepository
|
HistoryRepository.class, this::getHistoryRepository,
|
||||||
|
AnalyticsRepository.class, this::getAnalyticsRepository
|
||||||
);
|
);
|
||||||
return (Supplier<R>) repoSuppliers.get(type);
|
return (Supplier<R>) repoSuppliers.get(type);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable
|
||||||
Optional<TransactionCategory> findByName(String name);
|
Optional<TransactionCategory> findByName(String name);
|
||||||
List<TransactionCategory> findAllBaseCategories();
|
List<TransactionCategory> findAllBaseCategories();
|
||||||
List<TransactionCategory> findAll();
|
List<TransactionCategory> findAll();
|
||||||
|
TransactionCategory findRoot(long categoryId);
|
||||||
long insert(long parentId, String name, Color color);
|
long insert(long parentId, String name, Color color);
|
||||||
long insert(String name, Color color);
|
long insert(String name, Color color);
|
||||||
void update(long id, String name, Color color);
|
void update(long id, String name, Color color);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -68,4 +68,9 @@ public class JdbcDataSource implements DataSource {
|
||||||
public HistoryRepository getHistoryRepository() {
|
public HistoryRepository getHistoryRepository() {
|
||||||
return new JdbcHistoryRepository(getConnection());
|
return new JdbcHistoryRepository(getConnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AnalyticsRepository getAnalyticsRepository() {
|
||||||
|
return new JdbcAnalyticsRepository(getConnection());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@Override
|
||||||
public long insert(long parentId, String name, Color color) {
|
public long insert(long parentId, String name, Color color) {
|
||||||
return DbUtil.insertOne(
|
return DbUtil.insertOne(
|
||||||
|
@ -132,11 +139,11 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
|
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
|
||||||
return new TransactionCategory(
|
long id = rs.getLong("id");
|
||||||
rs.getLong("id"),
|
Long parentId = rs.getLong("parent_id");
|
||||||
rs.getObject("parent_id", Long.class),
|
if (rs.wasNull()) parentId = null;
|
||||||
rs.getString("name"),
|
String name = rs.getString("name");
|
||||||
Color.valueOf("#" + rs.getString("color"))
|
Color color = Color.valueOf("#" + rs.getString("color"));
|
||||||
);
|
return new TransactionCategory(id, parentId, name, color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,11 @@ import com.andrewlalis.perfin.data.DataSource;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashSet;
|
import java.util.*;
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -39,13 +39,32 @@ public record Profile(String name, Properties settings, DataSource dataSource) {
|
||||||
private static Profile current;
|
private static Profile current;
|
||||||
private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Path getProfilesDir() {
|
||||||
|
return PerfinApp.APP_DIR.resolve("profiles");
|
||||||
|
}
|
||||||
|
|
||||||
public static Path getDir(String name) {
|
public static Path getDir(String name) {
|
||||||
return PerfinApp.APP_DIR.resolve(name);
|
return getProfilesDir().resolve(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Path getContentDir(String name) {
|
public static Path getContentDir(String name) {
|
||||||
|
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.PerfinApp;
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
import com.andrewlalis.perfin.control.Popups;
|
import com.andrewlalis.perfin.control.Popups;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
import com.andrewlalis.perfin.data.DataSourceFactory;
|
import com.andrewlalis.perfin.data.DataSourceFactory;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
||||||
|
@ -11,18 +12,11 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.FileVisitResult;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
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.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipOutputStream;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
|
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.
|
// Check for a recent backup and make one if not present.
|
||||||
LocalDateTime lastBackup = getLastBackupTimestamp(name);
|
try {
|
||||||
if (lastBackup == null || lastBackup.isBefore(LocalDateTime.now().minusDays(5))) {
|
ProfileBackups.makeBackup(name);
|
||||||
try {
|
ProfileBackups.cleanOldBackups(name);
|
||||||
makeBackup(name);
|
} catch (IOException e) {
|
||||||
} catch (IOException e) {
|
log.error("Failed to create backup for profile " + name + ".", 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() {
|
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)
|
return files.filter(Files::isDirectory)
|
||||||
|
.filter(p -> !p.getFileName().toString().startsWith("."))
|
||||||
.map(path -> path.getFileName().toString())
|
.map(path -> path.getFileName().toString())
|
||||||
.sorted().toList();
|
.sorted().toList();
|
||||||
} catch (IOException e) {
|
} 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
|
@Deprecated
|
||||||
private static void initProfileDir(String name) throws IOException {
|
private static void initProfileDir(String name) throws IOException {
|
||||||
Files.createDirectory(Profile.getDir(name));
|
Files.createDirectory(Profile.getDir(name));
|
||||||
|
|
|
@ -16,4 +16,9 @@ public class TransactionTag extends IdEntity {
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,16 +1,42 @@
|
||||||
package com.andrewlalis.perfin.view.component.module;
|
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.chart.PieChart;
|
||||||
import javafx.scene.layout.Pane;
|
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) {
|
public SpendingCategoryChartModule(Pane parent) {
|
||||||
super(parent);
|
super(parent, "Spending by Category", "charts.category-spend.default-currency");
|
||||||
PieChart chart = new PieChart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue