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