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)) {
 | 
			
		||||
            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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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() {
 | 
			
		||||
        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
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.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));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,4 +16,9 @@ public class TransactionTag extends IdEntity {
 | 
			
		|||
    public String getName() {
 | 
			
		||||
        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;
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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