fix #37: monthly expense charts

:
This commit is contained in:
Vasily Zubarev
2025-08-02 21:28:47 +02:00
parent a5a2e3053b
commit 280adabc71
6 changed files with 565 additions and 2 deletions

View File

@@ -79,3 +79,245 @@ export const getProjectStats = cache(async (userId: string, projectId: string, f
invoicesProcessed,
}
})
export type TimeSeriesData = {
period: string
income: number
expenses: number
date: Date
}
export type CategoryBreakdown = {
code: string
name: string
color: string
income: number
expenses: number
transactionCount: number
}
export type DetailedTimeSeriesData = {
period: string
income: number
expenses: number
date: Date
categories: CategoryBreakdown[]
totalTransactions: number
}
export const getTimeSeriesStats = cache(
async (
userId: string,
filters: TransactionFilters = {},
defaultCurrency: string = "EUR"
): Promise<TimeSeriesData[]> => {
const where: Prisma.TransactionWhereInput = { userId }
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
if (filters.categoryCode) {
where.categoryCode = filters.categoryCode
}
if (filters.projectCode) {
where.projectCode = filters.projectCode
}
if (filters.type) {
where.type = filters.type
}
const transactions = await prisma.transaction.findMany({
where,
orderBy: { issuedAt: "asc" },
})
if (transactions.length === 0) {
return []
}
// Determine if we should group by day or month
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
const groupByDay = daysDiff <= 50
// Group transactions by time period
const grouped = transactions.reduce(
(acc, transaction) => {
if (!transaction.issuedAt) return acc
const date = new Date(transaction.issuedAt)
const period = groupByDay
? date.toISOString().split("T")[0] // YYYY-MM-DD
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
if (!acc[period]) {
acc[period] = { period, income: 0, expenses: 0, date }
}
// Get amount in default currency
const amount =
transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
? transaction.convertedTotal || 0
: transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
? transaction.total || 0
: 0 // Skip transactions not in default currency for simplicity
if (transaction.type === "income") {
acc[period].income += amount
} else if (transaction.type === "expense") {
acc[period].expenses += amount
}
return acc
},
{} as Record<string, TimeSeriesData>
)
return Object.values(grouped).sort((a, b) => a.date.getTime() - b.date.getTime())
}
)
export const getDetailedTimeSeriesStats = cache(
async (
userId: string,
filters: TransactionFilters = {},
defaultCurrency: string = "EUR"
): Promise<DetailedTimeSeriesData[]> => {
const where: Prisma.TransactionWhereInput = { userId }
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
if (filters.categoryCode) {
where.categoryCode = filters.categoryCode
}
if (filters.projectCode) {
where.projectCode = filters.projectCode
}
if (filters.type) {
where.type = filters.type
}
const [transactions, categories] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
category: true,
},
orderBy: { issuedAt: "asc" },
}),
prisma.category.findMany({
where: { userId },
orderBy: { name: "asc" },
}),
])
if (transactions.length === 0) {
return []
}
// Determine if we should group by day or month
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!)
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!)
const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24))
const groupByDay = daysDiff <= 50
// Create category lookup
const categoryLookup = new Map(categories.map((cat) => [cat.code, cat]))
// Group transactions by time period
const grouped = transactions.reduce(
(acc, transaction) => {
if (!transaction.issuedAt) return acc
const date = new Date(transaction.issuedAt)
const period = groupByDay
? date.toISOString().split("T")[0] // YYYY-MM-DD
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM
if (!acc[period]) {
acc[period] = {
period,
income: 0,
expenses: 0,
date,
categories: new Map<string, CategoryBreakdown>(),
totalTransactions: 0,
}
}
// Get amount in default currency
const amount =
transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
? transaction.convertedTotal || 0
: transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase()
? transaction.total || 0
: 0 // Skip transactions not in default currency for simplicity
const categoryCode = transaction.categoryCode || "other"
const category = categoryLookup.get(categoryCode) || {
code: "other",
name: "Other",
color: "#6b7280",
}
// Initialize category if not exists
if (!acc[period].categories.has(categoryCode)) {
acc[period].categories.set(categoryCode, {
code: category.code,
name: category.name,
color: category.color || "#6b7280",
income: 0,
expenses: 0,
transactionCount: 0,
})
}
const categoryData = acc[period].categories.get(categoryCode)!
categoryData.transactionCount++
acc[period].totalTransactions++
if (transaction.type === "income") {
acc[period].income += amount
categoryData.income += amount
} else if (transaction.type === "expense") {
acc[period].expenses += amount
categoryData.expenses += amount
}
return acc
},
{} as Record<
string,
{
period: string
income: number
expenses: number
date: Date
categories: Map<string, CategoryBreakdown>
totalTransactions: number
}
>
)
return Object.values(grouped)
.map((item) => ({
...item,
categories: Array.from(item.categories.values()).filter((cat) => cat.income > 0 || cat.expenses > 0),
}))
.sort((a, b) => a.date.getTime() - b.date.getTime())
}
)