Add /categories-cvs

This commit is contained in:
Steve Schafer 2026-01-17 10:01:05 -07:00
parent 31f9272409
commit 3e978a831c
15 changed files with 365 additions and 43 deletions

View file

@ -2,7 +2,6 @@
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="module" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
@ -38,5 +37,22 @@
<attribute name="org.eclipse.jst.component.dependency" value="/WEB-INF/lib"/>
</attributes>
</classpathentry>
<classpathentry kind="src" path="target/generated-sources/annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View file

@ -0,0 +1,2 @@
eclipse.preferences.version=1
org.eclipse.jdt.apt.aptEnabled=false

View file

@ -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

View file

@ -1,13 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?><project-modules id="moduleCoreId" project-version="1.5.0">
<wb-module deploy-name="budget.web-0.0.1-SNAPSHOT">
<wb-resource deploy-path="/" source-path="/target/m2e-wtp/web-resources"/>
<wb-resource deploy-path="/" source-path="/src/main/webapp"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/main/java"/>
<wb-resource deploy-path="/" source-path="/WebContent" tag="defaultRootSource"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/main/resources"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/test/java"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/test/resources"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/target/generated-sources/annotations"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/target/generated-test-sources/test-annotations"/>
<property name="java-output-path" value="/com.stephenschafer.budget.web/build/classes"/>
<property name="context-root" value="budget.web"/>
</wb-module>
</project-modules>

View file

@ -4,6 +4,6 @@
<fixed facet="java"/>
<fixed facet="wst.jsdt.web"/>
<installed facet="wst.jsdt.web" version="1.0"/>
<installed facet="jst.web" version="4.0"/>
<installed facet="java" version="17"/>
<installed facet="jst.web" version="2.5"/>
</faceted-project>

View file

@ -20,4 +20,15 @@
<url-pattern>/categories</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>CategoriesCsvPage</servlet-name>
<servlet-class>com.stephenschafer.budget.web.CategoriesCsvPage</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>CategoriesCsvPage</servlet-name>
<url-pattern>/categories-csv</url-pattern>
</servlet-mapping>
</web-app>

View file

@ -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

View file

@ -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<Integer, ReportCategory> 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<Integer, ReportCategory> 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;
}
}

View file

@ -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("</thead>");
out.println("<tbody>");
generateCategoryTable(out, rootCategory, 0);
generateCategoryTable(out, rootCategory, 0, monthCount);
out.println("</tbody>");
out.println("</table>");
out.println("</div>");
@ -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("</td>");
out.println("</tr>");
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<Integer, Category> loadCategories(final Connection connection, final String year,
private Map<Integer, ReportCategory> 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<Integer, Category> categories = new HashMap<>();
ReportCategory unknownCategory = null;
final Map<Integer, ReportCategory> 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()) {

View file

@ -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<Detail> details = new ArrayList<>();
private final Map<Integer, Category> children = new HashMap<>();
private List<ReportDetail> details = new ArrayList<>();
private Set<Integer> months = null;
private final Map<Integer, ReportCategory> 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<Integer, Category> categories) {
public void updateParent(final Map<Integer, ReportCategory> 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<Detail> getDetails() {
public List<ReportDetail> getDetails() {
return details;
}
public void setDetails(final List<Detail> details) {
public void setDetails(final List<ReportDetail> details) {
this.details = details;
}
public void addDetail(final Detail detail) {
public void addDetail(final ReportDetail detail) {
details.add(detail);
}
public Map<Integer, Category> getChildren() {
public Map<Integer, ReportCategory> 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<Integer> getMonths() {
Set<Integer> 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<Category> getChildCategories() {
final List<Category> categories = new ArrayList<>();
public List<ReportCategory> getChildCategories() {
final List<ReportCategory> categories = new ArrayList<>();
for (final Integer categoryId : children.keySet()) {
final var category = children.get(categoryId);
categories.add(category);

View file

@ -3,7 +3,7 @@ package com.stephenschafer.budget.web;
import java.math.BigDecimal;
import java.util.Date;
class Detail implements Comparable<Detail> {
class ReportDetail implements Comparable<ReportDetail> {
int transactionId;
String source;
String description;
@ -15,7 +15,7 @@ class Detail implements Comparable<Detail> {
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) {

View file

@ -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}

View file

@ -0,0 +1 @@
Category, Average, Total, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec

View file

@ -3,7 +3,7 @@
<h2 style="cursor: pointer; font-weight: normal;" onclick="showDetail(this, ${id})">${name}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ">${amount}</h2>
<h2 style="font-weight: normal; ">${average}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ">${total}</h2>

View file

@ -1,6 +1,6 @@
<tr>
<th>Category</th>
<th>Amount</th>
<th>Average</th>
<th>Total</th>
<th>Jan</th>
<th>Feb</th>