From 3e978a831c5678c61ba89b96c6ddeea931e554a8 Mon Sep 17 00:00:00 2001 From: Steve Schafer Date: Sat, 17 Jan 2026 10:01:05 -0700 Subject: [PATCH] Add /categories-cvs --- .classpath | 18 +- .settings/org.eclipse.jdt.apt.core.prefs | 2 + .settings/org.eclipse.jdt.core.prefs | 3 +- .settings/org.eclipse.wst.common.component | 14 +- ....eclipse.wst.common.project.facet.core.xml | 2 +- WebContent/WEB-INF/web.xml | 11 + build-war | 2 +- .../budget/web/CategoriesCsvPage.java | 242 ++++++++++++++++++ .../budget/web/CategoriesPage.java | 43 ++-- .../{Category.java => ReportCategory.java} | 61 +++-- .../web/{Detail.java => ReportDetail.java} | 4 +- src/main/resources/categoryCsv.template | 1 + src/main/resources/categoryCsvHead.template | 1 + src/main/resources/categoryHead.html | 2 +- src/main/resources/grandHead.html | 2 +- 15 files changed, 365 insertions(+), 43 deletions(-) create mode 100644 .settings/org.eclipse.jdt.apt.core.prefs create mode 100644 src/main/java/com/stephenschafer/budget/web/CategoriesCsvPage.java rename src/main/java/com/stephenschafer/budget/web/{Category.java => ReportCategory.java} (64%) rename src/main/java/com/stephenschafer/budget/web/{Detail.java => ReportDetail.java} (92%) create mode 100644 src/main/resources/categoryCsv.template create mode 100644 src/main/resources/categoryCsvHead.template diff --git a/.classpath b/.classpath index 1af8d6d..fa5557d 100644 --- a/.classpath +++ b/.classpath @@ -2,7 +2,6 @@ - @@ -38,5 +37,22 @@ + + + + + + + + + + + + + + + + + diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs new file mode 100644 index 0000000..d4313d4 --- /dev/null +++ b/.settings/org.eclipse.jdt.apt.core.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.apt.aptEnabled=false diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs index d089a9b..63d8af7 100644 --- a/.settings/org.eclipse.jdt.core.prefs +++ b/.settings/org.eclipse.jdt.core.prefs @@ -7,5 +7,6 @@ org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.enumIdentifier=error org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning -org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.processAnnotations=disabled +org.eclipse.jdt.core.compiler.release=disabled org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.wst.common.component b/.settings/org.eclipse.wst.common.component index d8b7229..50d727a 100644 --- a/.settings/org.eclipse.wst.common.component +++ b/.settings/org.eclipse.wst.common.component @@ -1,13 +1,23 @@ + + + + + + - - + + + + + + diff --git a/.settings/org.eclipse.wst.common.project.facet.core.xml b/.settings/org.eclipse.wst.common.project.facet.core.xml index 5509be0..ac63888 100644 --- a/.settings/org.eclipse.wst.common.project.facet.core.xml +++ b/.settings/org.eclipse.wst.common.project.facet.core.xml @@ -4,6 +4,6 @@ - + diff --git a/WebContent/WEB-INF/web.xml b/WebContent/WEB-INF/web.xml index 8f40f64..0178b4f 100644 --- a/WebContent/WEB-INF/web.xml +++ b/WebContent/WEB-INF/web.xml @@ -20,4 +20,15 @@ /categories + + CategoriesCsvPage + com.stephenschafer.budget.web.CategoriesCsvPage + 1 + + + + CategoriesCsvPage + /categories-csv + + diff --git a/build-war b/build-war index 2b2ae15..28c1cc7 100755 --- a/build-war +++ b/build-war @@ -1,5 +1,5 @@ #!/bin/sh -if mvn -f pom.xml clean install > build-jar.log 2> build-jar.err.log; then +if mvn -f pom.xml clean install > build-war.log 2> build-war.err.log; then echo "success" cp target/*.war /tmp else diff --git a/src/main/java/com/stephenschafer/budget/web/CategoriesCsvPage.java b/src/main/java/com/stephenschafer/budget/web/CategoriesCsvPage.java new file mode 100644 index 0000000..5b6bea3 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/CategoriesCsvPage.java @@ -0,0 +1,242 @@ +package com.stephenschafer.budget.web; + +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.Map; + +import javax.naming.NamingException; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class CategoriesCsvPage extends HttpServlet { + private static final long serialVersionUID = 1L; + + @Override + public void init(final ServletConfig config) throws ServletException { + try { + Configuration.INSTANCE.load(); + } + catch (final Exception e) { + e.printStackTrace(); + } + super.init(config); + } + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + var paramYear = request.getParameterValues("year"); + if (paramYear == null || paramYear.length == 0) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(new Date()); + final var year = cal.get(Calendar.YEAR); + paramYear = new String[] { String.valueOf(year) }; + } + var tmpIncludeExpenses = true; + var tmpIncludeIncome = true; + var tmpIncludePayments = false; + var tmpIncludeInvestments = false; + final var includes = request.getParameterValues("include"); + if (includes != null && includes.length > 0) { + tmpIncludeExpenses = false; + tmpIncludeIncome = false; + tmpIncludePayments = false; + tmpIncludeInvestments = false; + for (final String include : includes) { + if ("expenses".equalsIgnoreCase(include)) { + tmpIncludeExpenses = true; + } + else if ("income".equalsIgnoreCase(include)) { + tmpIncludeIncome = true; + } + else if ("payments".equalsIgnoreCase(include)) { + tmpIncludePayments = true; + } + else if ("investments".equalsIgnoreCase(include)) { + tmpIncludeInvestments = true; + } + } + } + final var includeExpenses = tmpIncludeExpenses; + final var includeIncome = tmpIncludeIncome; + final var includePayments = tmpIncludePayments; + final var includeInvestments = tmpIncludeInvestments; + final var startDates = request.getParameterValues("startDate"); + final var endDates = request.getParameterValues("endDate"); + final var startDateString = startDates == null ? null + : startDates.length == 0 ? null : startDates[0]; + final var endDateString = endDates == null ? null + : endDates.length == 0 ? null : endDates[0]; + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + final Date startDate; + final Date endDate; + try { + startDate = startDateString == null ? null : df.parse(startDateString); + endDate = endDateString == null ? null : df.parse(endDateString); + } + catch (final ParseException e) { + response.sendError(400, "Invalid date"); + return; + } + final Calendar startCal = new GregorianCalendar(); + startCal.setTime(startDate); + final int startMonth = startCal.get(Calendar.MONTH); + final Calendar endCal = new GregorianCalendar(); + endCal.setTime(endDate); + final int endMonth = endCal.get(Calendar.MONTH); + final int monthCount = endMonth - startMonth + 1; + final var out = response.getWriter(); + response.setHeader("Content-Type", "text/csv"); + try (var connection = Configuration.INSTANCE.getConnection()) { + for (final String year : paramYear) { + final var categoryMap = loadCategories(connection, year, includeIncome, + includePayments, includeInvestments, includeExpenses); + final var sql = Util.getResourceAsString("getTransactionDetail.sql") // + .replace("${databaseName}", "budget_" + year) // + .replace("${regexDatabaseName}", "budget"); + try (var stmt = connection.prepareStatement(sql)) { + try (var rs = stmt.executeQuery()) { + while (rs.next()) { + final var detail = new ReportDetail(); + var i = 0; + detail.transactionId = rs.getInt(++i); + detail.date = rs.getDate(++i); + detail.source = rs.getString(++i); + detail.description = rs.getString(++i); + detail.amount = rs.getBigDecimal(++i); + if (rs.wasNull()) { + detail.amount = new BigDecimal(0); + } + final var categoryId = rs.getInt(++i); + detail.regex = rs.getString(++i); + detail.flags = rs.getInt(++i); + detail.requiredSource = rs.getString(++i); + detail.extraDescription = rs.getString(++i); + final var category = categoryMap.get(categoryId); + if (category != null && // + detail.isAfter(startDate) && // + detail.isBefore(endDate) && // + category.isIncluded()) { + category.addDetail(detail); + } + } + } + } + final var rootCategory = new ReportCategory(-1, null, "root"); + for (final Integer categoryId : categoryMap.keySet()) { + final var category = categoryMap.get(categoryId); + if ((category != null) && (category.getParent() == null)) { + rootCategory.addChild(category); + } + } + out.print(Util.getResourceAsString("categoryCsvHead.template")); + generateCategoryTable(out, rootCategory, 0, monthCount); + } + } + catch (final SQLException | NamingException e) { + throw new ServletException(e); + } + } + + private void generateCategoryTable(final PrintWriter out, final ReportCategory parent, + final int level, final int monthCount) throws IOException { + final var categoryGrandAverage = parent.getGrandAverage(monthCount); + final var categoryGrandAmount = parent.getGrandTotal(); + var categoryCsvLine = Util.getResourceAsString("categoryCsv.template") // + .replace("${name}", parent.getQualifiedName()) // + .replace("${id}", parent.getId().toString()) // + .replace("${average}", categoryGrandAverage.toString()) // + .replace("${total}", categoryGrandAmount.toString()) // + .replace("${indent}", String.valueOf(level * 20) + "px"); + parent.getLargestMonth(); + for (int month = 0; month < 12; month++) { + categoryCsvLine = categoryCsvLine // + .replace("${total-" + month + "}", parent.getMonthGrandTotal(month).toString()); + } + out.print(categoryCsvLine); + final var categories = parent.getChildCategories(); + for (final ReportCategory category : categories) { + generateCategoryTable(out, category, level + 1, monthCount); + } + } + + interface CategoryFilter { + boolean include(ReportCategory category); + } + + private Map loadCategories(final Connection connection, final String year, + final boolean includeIncome, final boolean includePayments, + final boolean includeInvestments, final boolean includeExpenses) + throws SQLException, IOException { + ReportCategory unknownCategory = null; + final Map categories = new HashMap<>(); + final var sql = Util.getResourceAsString("getCategories.sql").replace("${databaseName}", + "budget"); + try (var stmt = connection.prepareStatement(sql)) { + try (var rs = stmt.executeQuery()) { + while (rs.next()) { + final Integer id = rs.getInt(1); + Integer parentId = rs.getInt(2); + if (rs.wasNull()) { + parentId = null; + } + final var name = rs.getString(3); + final var category = new ReportCategory(id, parentId, name); + categories.put(id, category); + if ("unknown".equals(name) && parentId == null) { + unknownCategory = category; + } + } + } + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + category.updateParent(categories); + } + if (unknownCategory == null) { + unknownCategory = new ReportCategory(-1, null, "unknown"); + categories.put(unknownCategory.getId(), unknownCategory); + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + final CategoryFilter filter = category1 -> { + final String categoryName = category1.getName(); + if ("income".equals(categoryName) || "refunds".equals(categoryName)) { + if (!includeIncome) { + return false; + } + } + else if ("payment".equals(categoryName)) { + if (!includePayments) { + return false; + } + } + else if ("investment".equals(categoryName)) { + if (!includeInvestments) { + return false; + } + } + else if (!includeExpenses) { + return false; + } + return true; + }; + final boolean included = filter.include(category); + category.setIncluded(included); + } + return categories; + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java b/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java index abfc330..977c15a 100644 --- a/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java +++ b/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java @@ -2,6 +2,7 @@ package com.stephenschafer.budget.web; import java.io.IOException; import java.io.PrintWriter; +import java.math.BigDecimal; import java.sql.Connection; import java.sql.SQLException; import java.text.DateFormat; @@ -94,6 +95,13 @@ public class CategoriesPage extends HttpServlet { response.sendError(400, "Invalid date"); return; } + final Calendar startCal = new GregorianCalendar(); + startCal.setTime(startDate); + final int startMonth = startCal.get(Calendar.MONTH); + final Calendar endCal = new GregorianCalendar(); + endCal.setTime(endDate); + final int endMonth = endCal.get(Calendar.MONTH); + final int monthCount = endMonth - startMonth + 1; final var out = response.getWriter(); response.setHeader("Content-Type", "text/html"); try (var connection = Configuration.INSTANCE.getConnection()) { @@ -118,13 +126,16 @@ public class CategoriesPage extends HttpServlet { try (var stmt = connection.prepareStatement(sql)) { try (var rs = stmt.executeQuery()) { while (rs.next()) { - final var detail = new Detail(); + final var detail = new ReportDetail(); var i = 0; detail.transactionId = rs.getInt(++i); detail.date = rs.getDate(++i); detail.source = rs.getString(++i); detail.description = rs.getString(++i); detail.amount = rs.getBigDecimal(++i); + if (rs.wasNull()) { + detail.amount = new BigDecimal(0); + } final var categoryId = rs.getInt(++i); detail.regex = rs.getString(++i); detail.flags = rs.getInt(++i); @@ -140,7 +151,7 @@ public class CategoriesPage extends HttpServlet { } } } - final var rootCategory = new Category(-1, null, "root"); + final var rootCategory = new ReportCategory(-1, null, "root"); for (final Integer categoryId : categoryMap.keySet()) { final var category = categoryMap.get(categoryId); if ((category != null) && (category.getParent() == null)) { @@ -155,7 +166,7 @@ public class CategoriesPage extends HttpServlet { out.println(grandHead); out.println(""); out.println(""); - generateCategoryTable(out, rootCategory, 0); + generateCategoryTable(out, rootCategory, 0, monthCount); out.println(""); out.println(""); out.println(""); @@ -168,15 +179,15 @@ public class CategoriesPage extends HttpServlet { } } - private void generateCategoryTable(final PrintWriter out, final Category parent, - final int level) throws IOException { + private void generateCategoryTable(final PrintWriter out, final ReportCategory parent, + final int level, final int monthCount) throws IOException { final var details = parent.getDetails(); - final var categoryAmount = parent.getDetailTotal(); + final var categoryGrandAverage = parent.getGrandAverage(monthCount); final var categoryGrandAmount = parent.getGrandTotal(); var categoryHeadHtml = Util.getResourceAsString("categoryHead.html") // .replace("${name}", parent.getName()) // .replace("${id}", parent.getId().toString()) // - .replace("${amount}", categoryAmount.toString()) // + .replace("${average}", categoryGrandAverage.toString()) // .replace("${total}", categoryGrandAmount.toString()) // .replace("${indent}", String.valueOf(level * 20) + "px"); final int largestMonth = parent.getLargestMonth(); @@ -195,7 +206,7 @@ public class CategoriesPage extends HttpServlet { out.print(categoryDetailHeadHtml); } Collections.sort(details); - for (final Detail detail : details) { + for (final ReportDetail detail : details) { final var categoryDetailHtml = Util.getResourceAsString("categoryDetail.html") // .replace("${source}", detail.source) // .replace("${description}", detail.description) // @@ -214,8 +225,8 @@ public class CategoriesPage extends HttpServlet { out.println(""); out.println(""); final var categories = parent.getChildCategories(); - for (final Category category : categories) { - generateCategoryTable(out, category, level + 1); + for (final ReportCategory category : categories) { + generateCategoryTable(out, category, level + 1, monthCount); } } @@ -252,15 +263,15 @@ public class CategoriesPage extends HttpServlet { } interface CategoryFilter { - boolean include(Category category); + boolean include(ReportCategory category); } - private Map loadCategories(final Connection connection, final String year, + private Map loadCategories(final Connection connection, final String year, final boolean includeIncome, final boolean includePayments, final boolean includeInvestments, final boolean includeExpenses) throws SQLException, IOException { - Category unknownCategory = null; - final Map categories = new HashMap<>(); + ReportCategory unknownCategory = null; + final Map categories = new HashMap<>(); final var sql = Util.getResourceAsString("getCategories.sql").replace("${databaseName}", "budget"); try (var stmt = connection.prepareStatement(sql)) { @@ -272,7 +283,7 @@ public class CategoriesPage extends HttpServlet { parentId = null; } final var name = rs.getString(3); - final var category = new Category(id, parentId, name); + final var category = new ReportCategory(id, parentId, name); categories.put(id, category); if ("unknown".equals(name) && parentId == null) { unknownCategory = category; @@ -285,7 +296,7 @@ public class CategoriesPage extends HttpServlet { category.updateParent(categories); } if (unknownCategory == null) { - unknownCategory = new Category(-1, null, "unknown"); + unknownCategory = new ReportCategory(-1, null, "unknown"); categories.put(unknownCategory.getId(), unknownCategory); } for (final Integer categoryId : categories.keySet()) { diff --git a/src/main/java/com/stephenschafer/budget/web/Category.java b/src/main/java/com/stephenschafer/budget/web/ReportCategory.java similarity index 64% rename from src/main/java/com/stephenschafer/budget/web/Category.java rename to src/main/java/com/stephenschafer/budget/web/ReportCategory.java index 91cbe43..7aaec23 100644 --- a/src/main/java/com/stephenschafer/budget/web/Category.java +++ b/src/main/java/com/stephenschafer/budget/web/ReportCategory.java @@ -1,29 +1,33 @@ package com.stephenschafer.budget.web; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; -public class Category { +public class ReportCategory { private final Integer id; private final Integer parentId; private final String name; - private Category parent; + private ReportCategory parent; private boolean included; - private List details = new ArrayList<>(); - private final Map children = new HashMap<>(); + private List details = new ArrayList<>(); + private Set months = null; + private final Map children = new HashMap<>(); - public Category(final Integer id, final Integer parentId, final String name) { + public ReportCategory(final Integer id, final Integer parentId, final String name) { this.id = id; this.parentId = parentId; this.name = name; } - public void updateParent(final Map categories) { + public void updateParent(final Map categories) { if (parentId == null) { parent = null; } @@ -33,15 +37,15 @@ public class Category { } } - public void addChild(final Category category) { + public void addChild(final ReportCategory category) { children.put(category.id, category); } - public Category getParent() { + public ReportCategory getParent() { return parent; } - public void setParent(final Category parent) { + public void setParent(final ReportCategory parent) { this.parent = parent; } @@ -67,25 +71,25 @@ public class Category { return sb.toString(); } - public List getDetails() { + public List getDetails() { return details; } - public void setDetails(final List details) { + public void setDetails(final List details) { this.details = details; } - public void addDetail(final Detail detail) { + public void addDetail(final ReportDetail detail) { details.add(detail); } - public Map getChildren() { + public Map getChildren() { return children; } public BigDecimal getDetailTotal() { var amount = new BigDecimal(0); - for (final Detail detail : details) { + for (final ReportDetail detail : details) { amount = amount.add(detail.amount); } return amount; @@ -93,7 +97,7 @@ public class Category { public BigDecimal getMonthTotal(final int month) { var amount = new BigDecimal(0); - for (final Detail detail : details) { + for (final ReportDetail detail : details) { final Calendar cal = new GregorianCalendar(); cal.setTime(detail.date); if (month == cal.get(Calendar.MONTH)) { @@ -103,6 +107,21 @@ public class Category { return amount; } + public Set getMonths() { + Set months = this.months; + if (months == null) { + months = new HashSet<>(); + for (final ReportDetail detail : details) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(detail.date); + final int month = cal.get(Calendar.MONTH); + months.add(Integer.valueOf(month)); + } + this.months = months; + } + return months; + } + public BigDecimal getGrandTotal() { var total = getDetailTotal(); for (final Integer categoryId : children.keySet()) { @@ -112,6 +131,14 @@ public class Category { return total; } + public BigDecimal getGrandAverage(final int monthCount) { + final BigDecimal grandTotal = getGrandTotal(); + if (monthCount == 0) { + return new BigDecimal(0); + } + return grandTotal.divide(new BigDecimal(monthCount), RoundingMode.HALF_DOWN); + } + public BigDecimal getMonthGrandTotal(final int month) { var total = getMonthTotal(month); for (final Integer categoryId : children.keySet()) { @@ -139,8 +166,8 @@ public class Category { return maxCount > 1 ? -1 : maxMonth; } - public List getChildCategories() { - final List categories = new ArrayList<>(); + public List getChildCategories() { + final List categories = new ArrayList<>(); for (final Integer categoryId : children.keySet()) { final var category = children.get(categoryId); categories.add(category); diff --git a/src/main/java/com/stephenschafer/budget/web/Detail.java b/src/main/java/com/stephenschafer/budget/web/ReportDetail.java similarity index 92% rename from src/main/java/com/stephenschafer/budget/web/Detail.java rename to src/main/java/com/stephenschafer/budget/web/ReportDetail.java index 32bce40..3143fee 100644 --- a/src/main/java/com/stephenschafer/budget/web/Detail.java +++ b/src/main/java/com/stephenschafer/budget/web/ReportDetail.java @@ -3,7 +3,7 @@ package com.stephenschafer.budget.web; import java.math.BigDecimal; import java.util.Date; -class Detail implements Comparable { +class ReportDetail implements Comparable { int transactionId; String source; String description; @@ -15,7 +15,7 @@ class Detail implements Comparable { String extraDescription; @Override - public int compareTo(final Detail arg1) { + public int compareTo(final ReportDetail arg1) { final var arg0 = this; int comparison; if (arg0.date == null) { diff --git a/src/main/resources/categoryCsv.template b/src/main/resources/categoryCsv.template new file mode 100644 index 0000000..5c2542e --- /dev/null +++ b/src/main/resources/categoryCsv.template @@ -0,0 +1 @@ +${name},${average}, ${total}, ${total-0}, ${total-1}, ${total-2}, ${total-3}, ${total-4}, ${total-5}, ${total-6}, ${total-7}, ${total-8}, ${total-9}, ${total-10}, ${total-11} diff --git a/src/main/resources/categoryCsvHead.template b/src/main/resources/categoryCsvHead.template new file mode 100644 index 0000000..91a7cac --- /dev/null +++ b/src/main/resources/categoryCsvHead.template @@ -0,0 +1 @@ +Category, Average, Total, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec diff --git a/src/main/resources/categoryHead.html b/src/main/resources/categoryHead.html index 982e5c5..e8828fc 100644 --- a/src/main/resources/categoryHead.html +++ b/src/main/resources/categoryHead.html @@ -3,7 +3,7 @@

${name}

-

${amount}

+

${average}

${total}

diff --git a/src/main/resources/grandHead.html b/src/main/resources/grandHead.html index de6f771..754676d 100644 --- a/src/main/resources/grandHead.html +++ b/src/main/resources/grandHead.html @@ -1,6 +1,6 @@ Category - Amount + Average Total Jan Feb