initial commit
This commit is contained in:
213
convex/dashboard.ts
Normal file
213
convex/dashboard.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAppSettings, requireUserId } from "./lib/helpers";
|
||||
import {
|
||||
buildSchedule,
|
||||
currentBalanceFromSchedule,
|
||||
totalMonthlyPaymentActiveLoans,
|
||||
totalRemainingDebt,
|
||||
} from "./lib/amortization";
|
||||
import { monthKeyFromBasis } from "./lib/month";
|
||||
|
||||
export const summary = query({
|
||||
args: {
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
},
|
||||
returns: v.object({
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
fixedCosts: v.number(),
|
||||
variableCosts: v.number(),
|
||||
balance: v.number(),
|
||||
savingsRate: v.union(v.number(), v.null()),
|
||||
totalLoanPayment: v.number(),
|
||||
totalRemainingDebt: v.number(),
|
||||
monthlyTrend: v.array(
|
||||
v.object({
|
||||
month: v.string(),
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
}),
|
||||
),
|
||||
categoryBreakdown: v.array(
|
||||
v.object({
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
name: v.string(),
|
||||
amount: v.number(),
|
||||
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||||
color: v.string(),
|
||||
}),
|
||||
),
|
||||
recentTransactions: v.array(
|
||||
v.object({
|
||||
_id: v.id("transactions"),
|
||||
bookingDate: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
amount: v.number(),
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
isPending: v.boolean(),
|
||||
}),
|
||||
),
|
||||
activeLoans: v.array(
|
||||
v.object({
|
||||
_id: v.id("loans"),
|
||||
name: v.string(),
|
||||
monthlyPayment: v.optional(v.number()),
|
||||
currentBalance: v.number(),
|
||||
remainingMonths: v.number(),
|
||||
payoffDate: v.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await getAppSettings(ctx, userId);
|
||||
|
||||
const categories = await ctx.db
|
||||
.query("categories")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const categoryById = new Map(categories.map((c) => [c._id, c]));
|
||||
|
||||
let transactions = await ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
|
||||
if (args.accountId) {
|
||||
transactions = transactions.filter((tx) => tx.accountId === args.accountId);
|
||||
}
|
||||
|
||||
transactions = transactions.filter((tx) => {
|
||||
const month = monthKeyFromBasis(tx, args.basis);
|
||||
if (!month) return false;
|
||||
const monthStart = `${month}-01`;
|
||||
const monthEnd = `${month}-31`;
|
||||
return monthStart >= `${args.from.slice(0, 7)}-01` && monthEnd <= `${args.to.slice(0, 7)}-31`;
|
||||
});
|
||||
|
||||
// Filter by date range using month keys
|
||||
transactions = transactions.filter((tx) => {
|
||||
const month = monthKeyFromBasis(tx, args.basis);
|
||||
if (!month) return false;
|
||||
return month >= args.from.slice(0, 7) && month <= args.to.slice(0, 7);
|
||||
});
|
||||
|
||||
let income = 0;
|
||||
let expenses = 0;
|
||||
let fixedCosts = 0;
|
||||
let variableCosts = 0;
|
||||
|
||||
const monthlyMap = new Map<string, { income: number; expenses: number }>();
|
||||
const categoryMap = new Map<string, { categoryId?: typeof categories[0]["_id"]; name: string; amount: number; block?: typeof categories[0]["block"]; color: string }>();
|
||||
|
||||
for (const tx of transactions) {
|
||||
const month = monthKeyFromBasis(tx, args.basis)!;
|
||||
const entry = monthlyMap.get(month) ?? { income: 0, expenses: 0 };
|
||||
if (tx.amount > 0) {
|
||||
income += tx.amount;
|
||||
entry.income += tx.amount;
|
||||
} else {
|
||||
expenses += tx.amount;
|
||||
entry.expenses += tx.amount;
|
||||
const cat = tx.categoryId ? categoryById.get(tx.categoryId) : undefined;
|
||||
if (cat?.block === "wiederkehrend") fixedCosts += tx.amount;
|
||||
if (cat?.block === "variabel") variableCosts += tx.amount;
|
||||
|
||||
const key = tx.categoryId ?? "none";
|
||||
const catEntry = categoryMap.get(key) ?? {
|
||||
categoryId: tx.categoryId,
|
||||
name: cat?.name ?? "Ohne Kategorie",
|
||||
amount: 0,
|
||||
block: cat?.block,
|
||||
color: cat?.color ?? "#94a3b8",
|
||||
};
|
||||
catEntry.amount += tx.amount;
|
||||
categoryMap.set(key, catEntry);
|
||||
}
|
||||
monthlyMap.set(month, entry);
|
||||
}
|
||||
|
||||
const balance = income + expenses;
|
||||
const savingsRate = income > 0 ? balance / income : null;
|
||||
|
||||
const monthlyTrend = [...monthlyMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, data]) => ({
|
||||
month,
|
||||
income: Math.round(data.income * 100) / 100,
|
||||
expenses: Math.round(data.expenses * 100) / 100,
|
||||
balance: Math.round((data.income + data.expenses) * 100) / 100,
|
||||
}));
|
||||
|
||||
const categoryBreakdown = [...categoryMap.values()]
|
||||
.sort((a, b) => a.amount - b.amount)
|
||||
.map((c) => ({ ...c, amount: Math.round(c.amount * 100) / 100 }));
|
||||
|
||||
const recentTransactions = (await ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (q) => q.eq("userId", userId))
|
||||
.order("desc")
|
||||
.take(10))
|
||||
.filter((tx) => !args.accountId || tx.accountId === args.accountId)
|
||||
.map((tx) => ({
|
||||
_id: tx._id,
|
||||
bookingDate: tx.bookingDate,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
categoryId: tx.categoryId,
|
||||
isPending: tx.isPending,
|
||||
}));
|
||||
|
||||
const loans = await ctx.db
|
||||
.query("loans")
|
||||
.withIndex("by_user_status", (q) => q.eq("userId", userId).eq("status", "aktiv"))
|
||||
.collect();
|
||||
|
||||
const activeLoans = loans.map((loan) => {
|
||||
const startDate = new Date(loan.startDate);
|
||||
const scheduleResult = buildSchedule({
|
||||
principal: loan.principal,
|
||||
annualRate: loan.annualInterestRate,
|
||||
startDate,
|
||||
monthlyPayment: loan.monthlyPayment,
|
||||
termMonths: loan.termMonths,
|
||||
});
|
||||
const currentBalance =
|
||||
loan.currentBalance ??
|
||||
currentBalanceFromSchedule(scheduleResult.schedule, startDate);
|
||||
const monthsElapsed = Math.max(
|
||||
0,
|
||||
(new Date().getFullYear() - startDate.getFullYear()) * 12 +
|
||||
(new Date().getMonth() - startDate.getMonth()),
|
||||
);
|
||||
return {
|
||||
_id: loan._id,
|
||||
name: loan.name,
|
||||
monthlyPayment: loan.monthlyPayment,
|
||||
currentBalance,
|
||||
remainingMonths: Math.max(0, scheduleResult.termMonths - monthsElapsed),
|
||||
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
income: Math.round(income * 100) / 100,
|
||||
expenses: Math.round(expenses * 100) / 100,
|
||||
fixedCosts: Math.round(fixedCosts * 100) / 100,
|
||||
variableCosts: Math.round(variableCosts * 100) / 100,
|
||||
balance: Math.round(balance * 100) / 100,
|
||||
savingsRate: savingsRate === null ? null : Math.round(savingsRate * 1000) / 1000,
|
||||
totalLoanPayment: totalMonthlyPaymentActiveLoans(loans),
|
||||
totalRemainingDebt: totalRemainingDebt(loans),
|
||||
monthlyTrend,
|
||||
categoryBreakdown,
|
||||
recentTransactions,
|
||||
activeLoans,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user