task: added side scroll tip for rebalance table, added limitations to transaction table in recent activity chart (dashbaord)
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 26s

This commit is contained in:
2026-03-25 23:27:59 -05:00
parent 86e217c040
commit cb87c906c8
4 changed files with 304 additions and 63 deletions

View File

@@ -1,4 +1,5 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { getUserMidnightFromDateOnly } from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
@@ -11,9 +12,11 @@ const monthKey = (date: Date) =>
const monthLabel = (date: Date) =>
date.toLocaleString("en-US", { month: "short", year: "numeric" });
function buildMonthBuckets(count: number, now = new Date()) {
function buildMonthBuckets(count: number, pageOffset: number, now = new Date()) {
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const current = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - pageOffset * count, 1)
);
for (let i = count - 1; i >= 0; i--) {
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
@@ -23,15 +26,28 @@ function buildMonthBuckets(count: number, now = new Date()) {
}
const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.get("/dashboard", async (req) => {
app.get("/dashboard", async (req, reply) => {
const Query = z.object({
trendPage: z.coerce.number().int().min(0).default(0),
trendMonths: z.coerce.number().int().min(1).max(12).default(6),
});
const parsedQuery = Query.safeParse(req.query ?? {});
if (!parsedQuery.success) {
return reply.code(400).send({ message: "Invalid dashboard query" });
}
const userId = req.userId;
const monthsBack = 6;
const buckets = buildMonthBuckets(monthsBack);
const rangeStart = buckets[0]?.start ?? new Date();
const { trendPage, trendMonths } = parsedQuery.data;
const now = new Date();
const buckets = buildMonthBuckets(trendMonths, trendPage, now);
const rangeStart = buckets[0]?.start ?? new Date();
const rangeEnd =
buckets[buckets.length - 1]?.end ??
new Date(rangeStart.getTime() + 31 * DAY_MS);
const defaultSixMonthStart = buildMonthBuckets(6, 0, now)[0]?.start ?? rangeStart;
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, firstIncomeEvent, spendTxs, user] = await Promise.all([
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, firstIncomeEvent, spendTxs, earliestSpendTx, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
@@ -43,7 +59,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.prisma.transaction.findMany({
where: { userId, kind: { in: dashboardTxKinds } },
orderBy: { occurredAt: "desc" },
take: 50,
take: 20,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
app.prisma.incomeEvent.aggregate({
@@ -57,7 +73,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.prisma.incomeEvent.findMany({
where: {
userId,
postedAt: { gte: rangeStart },
postedAt: { gte: rangeStart, lt: rangeEnd },
},
select: { id: true, postedAt: true, amountCents: true, note: true },
}),
@@ -70,10 +86,18 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
where: {
userId,
kind: { in: dashboardTxKinds },
occurredAt: { gte: rangeStart },
occurredAt: { gte: rangeStart, lt: rangeEnd },
},
select: { occurredAt: true, amountCents: true },
}),
app.prisma.transaction.findFirst({
where: {
userId,
kind: { in: dashboardTxKinds },
},
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
}),
app.prisma.user.findUnique({
where: { id: userId },
select: {
@@ -174,7 +198,21 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
spendCents: spendByMonth.get(bucket.key) ?? 0,
}));
const hasTransactionsOlderThanSixMonths =
!!earliestSpendTx?.occurredAt &&
earliestSpendTx.occurredAt.getTime() < defaultSixMonthStart.getTime();
const hasOlderTrendWindow =
hasTransactionsOlderThanSixMonths &&
!!earliestSpendTx?.occurredAt &&
earliestSpendTx.occurredAt.getTime() < rangeStart.getTime();
const startLabel = buckets[0]?.label ?? "";
const endLabel = buckets[buckets.length - 1]?.label ?? "";
const rangeLabel =
startLabel && endLabel
? startLabel === endLabel
? startLabel
: `${startLabel} - ${endLabel}`
: "Trend window";
const upcomingPlans = fixedPlans
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
.filter(
@@ -279,6 +317,16 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
},
monthlyTrend,
trendWindow: {
page: trendPage,
months: trendMonths,
canGoNewer: trendPage > 0,
canGoOlder: hasOlderTrendWindow,
hasTransactionsOlderThanSixMonths,
startLabel,
endLabel,
label: rangeLabel,
},
upcomingPlans,
savingsTargets,
crisis: {