Added aggregate data for search page.
Build and Deploy Web App / build-and-deploy (push) Successful in 28s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m53s Details

This commit is contained in:
Andrew Lalis 2026-04-23 16:48:48 -04:00
parent bac074599a
commit 6cc29589ba
9 changed files with 172 additions and 52 deletions

View File

@ -17,6 +17,7 @@ import account.api;
import util.money; import util.money;
import util.pagination; import util.pagination;
import util.data; import util.data;
import transaction.search_filters;
// Transactions API // Transactions API
@ -32,12 +33,21 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
@GetMapping(PROFILE_PATH ~ "/transactions/search") @GetMapping(PROFILE_PATH ~ "/transactions/search")
void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import transaction.search_filters : extractSearchParams;
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
auto page = ds.getTransactionRepository().search(pr, request); auto page = ds.getTransactionRepository().search(pr, extractSearchParams(request));
writeJsonBody(response, page); writeJsonBody(response, page);
} }
@GetMapping(PROFILE_PATH ~ "/transactions/aggregate-data")
void handleGetTransactionAggregateData(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import transaction.search_filters : extractSearchParams;
ProfileDataSource ds = getProfileDataSource(request);
auto aggregateData = ds.getTransactionRepository().getAggregateData(extractSearchParams(request));
writeJsonBody(response, aggregateData);
}
@GetMapping(PROFILE_PATH ~ "/transactions/export") @GetMapping(PROFILE_PATH ~ "/transactions/export")
void handleExportTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleExportTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);

View File

@ -1,6 +1,6 @@
module transaction.data; module transaction.data;
import handy_http_primitives : Optional, ServerHttpRequest; import handy_http_primitives : Optional, StringMultiValueMap;
import std.datetime; import std.datetime;
import transaction.model; import transaction.model;
@ -44,7 +44,8 @@ interface TransactionTagRepository {
interface TransactionRepository { interface TransactionRepository {
Page!TransactionsListItem findAll(in PageRequest pr); Page!TransactionsListItem findAll(in PageRequest pr);
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request); Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams);
AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams);
Optional!TransactionDetail findById(ulong id); Optional!TransactionDetail findById(ulong id);
TransactionDetail insert(in AddTransactionPayload data); TransactionDetail insert(in AddTransactionPayload data);
void linkAttachment(ulong transactionId, ulong attachmentId); void linkAttachment(ulong transactionId, ulong attachmentId);

View File

@ -1,6 +1,6 @@
module transaction.data_impl_sqlite; module transaction.data_impl_sqlite;
import handy_http_primitives : Optional, ServerHttpRequest; import handy_http_primitives : Optional, StringMultiValueMap;
import std.datetime; import std.datetime;
import std.typecons; import std.typecons;
import d2sqlite3; import d2sqlite3;
@ -293,7 +293,7 @@ class SqliteTransactionRepository : TransactionRepository {
return Page!(TransactionsListItem).of(results, pr, totalCount); return Page!(TransactionsListItem).of(results, pr, totalCount);
} }
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) { Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams) {
import std.algorithm; import std.algorithm;
import std.conv; import std.conv;
import std.string : join; import std.string : join;
@ -303,7 +303,7 @@ class SqliteTransactionRepository : TransactionRepository {
// 1. Get the total count of transactions that match the search filters. // 1. Get the total count of transactions that match the search filters.
qb.select("COUNT (DISTINCT txn.id)"); qb.select("COUNT (DISTINCT txn.id)");
applyFilters(qb, request, new SqliteTransactionCategoryRepository(db)); applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
string countQuery = qb.build(); string countQuery = qb.build();
Statement countStmt = db.prepare(countQuery); Statement countStmt = db.prepare(countQuery);
qb.applyArgBindings(countStmt); qb.applyArgBindings(countStmt);
@ -335,6 +335,38 @@ class SqliteTransactionRepository : TransactionRepository {
return Page!TransactionsListItem.of(results, pr, count); return Page!TransactionsListItem.of(results, pr, count);
} }
AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams) {
import transaction.search_filters;
// Start by using the standard transactions-list query builder to apply filters.
QueryBuilder qb = getBuilderForTransactionsList();
qb.select("txn.currency AS currency, j_credit.amount AS credits, j_debit.amount AS debits");
applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
string baseQuery = qb.build() ~ "\nGROUP BY txn.id";
// Now wrap that in a separate query that aggregates credits & debits for each transaction.
string aggregateQuery = QueryBuilder("(" ~ baseQuery ~ ") AS base")
.select("base.currency")
.select("SUM(base.credits)")
.select("SUM(base.debits)")
.build() ~ " GROUP BY base.currency";
Statement stmt = db.prepare(aggregateQuery);
// Use the base query's argument bindings to apply to the DB statement.
qb.applyArgBindings(stmt);
ResultRange result = stmt.execute();
// Collect the results for each currency into
AggregateTransactionData aggregateData;
while (!result.empty()) {
AggregateTransactionData.CurrencyData currencyData;
currencyData.credits = result.front.peek!ulong(1);
currencyData.debits = result.front.peek!ulong(2);
currencyData.balance = currencyData.debits - currencyData.credits;
currencyData.currency = Currency.ofCode(result.front.peek!(string, PeekMode.slice)(0));
aggregateData.currencies ~= currencyData;
result.popFront();
}
return aggregateData;
}
Optional!TransactionDetail findById(ulong id) { Optional!TransactionDetail findById(ulong id) {
Optional!TransactionDetail item = util.sqlite.findOne( Optional!TransactionDetail item = util.sqlite.findOne(
db, db,

View File

@ -155,3 +155,18 @@ struct TransactionCategoryBalance {
long balance; long balance;
Currency currency; Currency currency;
} }
/**
* A set of aggregate data computed from a set of transactions obtained in a
* search. Data is grouped by currency.
*/
struct AggregateTransactionData {
/// Aggregate data about all transactions with a common currency.
static struct CurrencyData {
ulong credits;
ulong debits;
long balance;
Currency currency;
}
CurrencyData[] currencies;
}

View File

@ -20,24 +20,24 @@ import transaction.data;
* Applies a set of filters to a query builder for searching over transactions. * Applies a set of filters to a query builder for searching over transactions.
* Params: * Params:
* qb = The query builder to add WHERE clauses and argument bindings to. * qb = The query builder to add WHERE clauses and argument bindings to.
* request = The request to get filter options from. * searchParams = The set of search parameters provided by the user.
* categoryRepo = Repository for fetching category data, which might be * categoryRepo = Repository for fetching category data, which might be
* needed if the user is filtering by a parent category. * needed if the user is filtering by a parent category.
*/ */
void applyFilters( void applyFilters(
ref QueryBuilder qb, ref QueryBuilder qb,
in ServerHttpRequest request, in StringMultiValueMap searchParams,
TransactionCategoryRepository categoryRepo TransactionCategoryRepository categoryRepo
) { ) {
applyPropertyInFilter!string(qb, request, "tags.tag", "tag"); applyPropertyInFilter!string(qb, searchParams, "tags.tag", "tag");
applyPropertyInFilter!ulong(qb, request, "vendor.id", "vendor"); applyPropertyInFilter!ulong(qb, searchParams, "vendor.id", "vendor");
applyPropertyInFilter!string(qb, request, "txn.currency", "currency"); applyPropertyInFilter!string(qb, searchParams, "txn.currency", "currency");
applyPropertyInFilter!ulong(qb, request, "account_credit.id", "credited-account"); applyPropertyInFilter!ulong(qb, searchParams, "account_credit.id", "credited-account");
applyPropertyInFilter!ulong(qb, request, "account_debit.id", "debited-account"); applyPropertyInFilter!ulong(qb, searchParams, "account_debit.id", "debited-account");
// Separate filter that combines both credit and debit accounts. // Separate filter that combines both credit and debit accounts.
if (request.hasParam("account")) { if (searchParams.contains("account")) {
ulong[] accountIds = request.getParamValues!ulong("account"); ulong[] accountIds = searchParams.getAllValuesAs!ulong("account");
string inStr = "(" ~ "?".repeat(accountIds.length).join(",") ~ ")"; string inStr = "(" ~ "?".repeat(accountIds.length).join(",") ~ ")";
qb.where("(account_credit.id IN " ~ inStr ~ " OR account_debit.id IN " ~ inStr ~ ")"); qb.where("(account_credit.id IN " ~ inStr ~ " OR account_debit.id IN " ~ inStr ~ ")");
qb.withArgBinding((ref stmt, ref idx) { qb.withArgBinding((ref stmt, ref idx) {
@ -53,8 +53,8 @@ void applyFilters(
// Separate filter that handles the hierarchical category relationship, so // Separate filter that handles the hierarchical category relationship, so
// if a parent category is filtered, all children are also included. // if a parent category is filtered, all children are also included.
if (request.hasParam("category")) { if (searchParams.contains("category")) {
ulong[] categoryIds = request.getParamValues!ulong("category"); ulong[] categoryIds = searchParams.getAllValuesAs!ulong("category");
auto app = appender!(ulong[]); auto app = appender!(ulong[]);
foreach (id; categoryIds) { foreach (id; categoryIds) {
app ~= getAllPossibleCategoryIds(id, categoryRepo); app ~= getAllPossibleCategoryIds(id, categoryRepo);
@ -70,8 +70,8 @@ void applyFilters(
} }
} }
if (request.hasParam("min-amount")) { if (searchParams.contains("min-amount")) {
ulong[] values = request.getParamValues!ulong("min-amount"); ulong[] values = searchParams.getAllValuesAs!ulong("min-amount");
if (values.length > 0) { if (values.length > 0) {
ulong minAmount = values[0]; ulong minAmount = values[0];
qb.where("txn.amount >= ?"); qb.where("txn.amount >= ?");
@ -81,8 +81,8 @@ void applyFilters(
} }
} }
if (request.hasParam("max-amount")) { if (searchParams.contains("max-amount")) {
ulong[] values = request.getParamValues!ulong("max-amount"); ulong[] values = searchParams.getAllValuesAs!ulong("max-amount");
if (values.length > 0) { if (values.length > 0) {
ulong minAmount = values[0]; ulong minAmount = values[0];
qb.where("txn.amount <= ?"); qb.where("txn.amount <= ?");
@ -93,10 +93,9 @@ void applyFilters(
} }
// Boolean filter for internal transfer. // Boolean filter for internal transfer.
if (request.hasParam("internal-transfer")) { if (searchParams.contains("internal-transfer")) {
string value = request.getParamValues("internal-transfer")[0] string value = searchParams.getFirst("internal-transfer").orElseThrow()
.strip() .strip().toUpper();
.toUpper();
Optional!bool internalTransferFilter = Optional!bool.empty(); Optional!bool internalTransferFilter = Optional!bool.empty();
if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") { if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") {
internalTransferFilter = Optional!bool.of(true); internalTransferFilter = Optional!bool.of(true);
@ -112,8 +111,8 @@ void applyFilters(
} }
// Textual search query: // Textual search query:
if (request.hasParam("q")) { if (searchParams.contains("q")) {
string searchQuery = request.getParamValues!string("q")[0]; string searchQuery = searchParams.getFirst("q").orElseThrow();
string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%"; string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%";
const string[] conditions = [ const string[] conditions = [
"UPPER(txn.description) LIKE ?", "UPPER(txn.description) LIKE ?",
@ -132,14 +131,36 @@ void applyFilters(
} }
} }
/**
* Helper function to extract query parameters into a multi-value map to be
* used more generally for other functions that require this format for search
* parameters.
* Params:
* request = The HTTP request to extract parameters from.
* Returns: A string-string multi-value map.
*/
StringMultiValueMap extractSearchParams(in ServerHttpRequest request) {
auto searchParamsBuilder = StringMultiValueMap.Builder();
foreach (queryParam; request.queryParams) {
foreach (value; queryParam.values) {
searchParamsBuilder.add(queryParam.key, value);
}
}
return searchParamsBuilder.build();
}
private T[] getAllValuesAs(T)(in StringMultiValueMap m, string key) {
return m.getAll(key).map!(v => v.to!T).array;
}
private void applyPropertyInFilter(T)( private void applyPropertyInFilter(T)(
ref QueryBuilder qb, ref QueryBuilder qb,
in ServerHttpRequest request, in StringMultiValueMap searchParams,
string property, string property,
string key string key
) { ) {
if (request.hasParam(key)) { if (searchParams.contains(key)) {
T[] values = request.getParamValues!T(key); T[] values = searchParams.getAllValuesAs!T(key);
qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")"); qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")");
qb.withArgBinding((ref stmt, ref idx) { qb.withArgBinding((ref stmt, ref idx) {
foreach (value; values) { foreach (value; values) {
@ -150,22 +171,6 @@ private void applyPropertyInFilter(T)(
} }
} }
private bool hasParam(in ServerHttpRequest request, string key) {
foreach (param; request.queryParams) {
if (param.key == key && param.values.length > 0) return true;
}
return false;
}
private T[] getParamValues(T = string)(in ServerHttpRequest request, string key) {
foreach (param; request.queryParams) {
if (param.key == key) {
return param.values.map!(s => s.to!T).array;
}
}
return [];
}
private ulong[] getAllPossibleCategoryIds(ulong parentId, TransactionCategoryRepository categoryRepo) { private ulong[] getAllPossibleCategoryIds(ulong parentId, TransactionCategoryRepository categoryRepo) {
auto app = appender!(ulong[]); auto app = appender!(ulong[]);
app ~= parentId; app ~= parentId;

View File

@ -9,6 +9,7 @@ import transaction.api;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import transaction.dto; import transaction.dto;
import transaction.search_filters : extractSearchParams;
import profile.data; import profile.data;
import account.model; import account.model;
import account.data; import account.data;
@ -285,7 +286,7 @@ void exportTransactionsToFile(
ref ServerHttpResponse response ref ServerHttpResponse response
) { ) {
Page!TransactionsListItem data = ds.getTransactionRepository() Page!TransactionsListItem data = ds.getTransactionRepository()
.search(PageRequest.unpaged(), request); .search(PageRequest.unpaged(), extractSearchParams(request));
ExportFileFormat fileFormat = getPreferredExportFileFormat(request); ExportFileFormat fileFormat = getPreferredExportFileFormat(request);
if (fileFormat == ExportFileFormat.JSON) { if (fileFormat == ExportFileFormat.JSON) {
import handy_http_data : writeJsonBody; import handy_http_data : writeJsonBody;

View File

@ -133,6 +133,17 @@ export interface CreateCategoryPayload {
parentId: number | null parentId: number | null
} }
export interface AggregateTransactionCurrencyData {
credits: number
debits: number
balance: number
currency: Currency
}
export interface AggregateTransactionData {
currencies: AggregateTransactionCurrencyData[]
}
export class TransactionApiClient extends ApiClient { export class TransactionApiClient extends ApiClient {
readonly path: string readonly path: string
@ -225,6 +236,10 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/transactions/search?' + params.toString()) return super.getJson(this.path + '/transactions/search?' + params.toString())
} }
getAggregateTransactionData(params: URLSearchParams): Promise<AggregateTransactionData> {
return super.getJson(this.path + '/transactions/aggregate-data?' + params.toString())
}
exportTransactions(params: URLSearchParams): Promise<void> { exportTransactions(params: URLSearchParams): Promise<void> {
return super.getFile(this.path + '/transactions/export?' + params.toString()) return super.getFile(this.path + '/transactions/export?' + params.toString())
} }

View File

@ -14,6 +14,10 @@
margin-top: 0; margin-top: 0;
} }
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-0 { .mb-0 {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Account, AccountApiClient } from '@/api/account' import { type Account, AccountApiClient } from '@/api/account'
import { formatMoney } from '@/api/data'
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination' import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination'
import { getSelectedProfile } from '@/api/profile' import { getSelectedProfile } from '@/api/profile'
import { import {
TransactionApiClient, TransactionApiClient,
type AggregateTransactionData,
type TransactionCategory, type TransactionCategory,
type TransactionVendor, type TransactionVendor,
type TransactionsListItem, type TransactionsListItem,
@ -36,6 +38,7 @@ const FETCH_DEBOUNCE_DELAY = 300
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const page: Ref<Page<TransactionsListItem>> = ref(defaultPage()) const page: Ref<Page<TransactionsListItem>> = ref(defaultPage())
const aggregateData: Ref<AggregateTransactionData | undefined> = ref()
const lastFetchTime = ref(0) const lastFetchTime = ref(0)
const fetchTimeoutId = ref<number | undefined>(undefined) const fetchTimeoutId = ref<number | undefined>(undefined)
@ -134,6 +137,10 @@ async function fetchPage(pg: number, size: number) {
const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString() const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString()
const api = new TransactionApiClient(getSelectedProfile(route)) const api = new TransactionApiClient(getSelectedProfile(route))
try { try {
// Update aggregate data (asynchronously from main fetching).
aggregateData.value = undefined
api.getAggregateTransactionData(params).then((data) => (aggregateData.value = data))
// Fetch the main page of transactions.
const startTime = performance.now() const startTime = performance.now()
page.value = await api.searchTransactions(params, pageRequest) page.value = await api.searchTransactions(params, pageRequest)
const endTime = performance.now() const endTime = performance.now()
@ -348,11 +355,41 @@ function loadAllParamValues(key: string): string[] {
@update="(pr) => fetchPage(pr.page, pr.size)" @update="(pr) => fetchPage(pr.page, pr.size)"
class="align-right" class="align-right"
/> />
<AppBadge size="sm"> <div>
{{ page.totalElements }} search <AppBadge size="sm">
{{ page.totalElements == 1 ? 'result' : 'results' }} {{ page.totalElements }} search
in {{ lastFetchTime }} milliseconds {{ page.totalElements == 1 ? 'result' : 'results' }}
</AppBadge> in {{ lastFetchTime }} milliseconds
</AppBadge>
</div>
<div v-if="aggregateData">
<div
v-for="data in aggregateData.currencies"
:key="data.currency.code"
class="mt-1 mb-1"
>
<AppBadge size="sm">
{{ data.currency.code }} Credits:
<span class="font-mono text-muted">{{ formatMoney(data.credits, data.currency) }}</span>
</AppBadge>
<AppBadge size="sm">
{{ data.currency.code }} Debits:
<span class="font-mono text-muted">{{ formatMoney(data.debits, data.currency) }}</span>
</AppBadge>
<AppBadge size="sm">
{{ data.currency.code }} Total:
<span
class="font-mono"
:class="{
'text-positive': data.balance > 0,
'text-negative': data.balance < 0,
}"
>
{{ formatMoney(data.balance, data.currency) }}
</span>
</AppBadge>
</div>
</div>
<TransactionCard <TransactionCard
v-for="txn in page.items" v-for="txn in page.items"
:key="txn.id" :key="txn.id"