Task: added pagination to records page, removed initial onboarding income from activity chart
Some checks failed
Deploy / deploy (push) Failing after 23s
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 22:36:10 -05:00
parent ad82e914fc
commit 1bd550b428
9 changed files with 117 additions and 20 deletions

0
api/skymoney-api@0.1.0 Normal file
View File

View File

@@ -3,6 +3,7 @@ import { getUserMidnightFromDateOnly } from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const ONBOARDING_SEED_NOTE_MARKER = "[onboarding-seed]";
const monthKey = (date: Date) =>
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
@@ -30,7 +31,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
const now = new Date();
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, firstIncomeEvent, spendTxs, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
@@ -54,8 +55,16 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
_sum: { amountCents: true },
}),
app.prisma.incomeEvent.findMany({
where: { userId, postedAt: { gte: rangeStart } },
select: { postedAt: true, amountCents: true },
where: {
userId,
postedAt: { gte: rangeStart },
},
select: { id: true, postedAt: true, amountCents: true, note: true },
}),
app.prisma.incomeEvent.findFirst({
where: { userId },
orderBy: { postedAt: "asc" },
select: { id: true },
}),
app.prisma.transaction.findMany({
where: {
@@ -140,8 +149,17 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
};
const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0);
const filteredIncomeEvents = incomeEvents.filter((evt) => {
if (evt.id.startsWith("onboarding-")) return false;
const note = evt.note?.trim().toLowerCase() ?? "";
if (note.includes(ONBOARDING_SEED_NOTE_MARKER)) return false;
// Legacy onboarding seed for regular users before explicit marker.
if (evt.id === firstIncomeEvent?.id && note.includes("initial budget setup")) return false;
return true;
});
const incomeByMonth = new Map<string, number>();
incomeEvents.forEach((evt) => {
filteredIncomeEvents.forEach((evt) => {
const key = monthKey(evt.postedAt);
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
});

View File

@@ -34,6 +34,39 @@ type TransactionsRoutesOptions = {
};
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const parseDateSearchTerm = (value: string): string | null => {
const trimmed = value.trim();
if (isDate(trimmed)) return trimmed;
const match = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/);
if (!match) return null;
const month = Number(match[1]);
const day = Number(match[2]);
const year = Number(match[3]);
if (
!Number.isInteger(month) ||
!Number.isInteger(day) ||
!Number.isInteger(year) ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return null;
}
const normalized = new Date(Date.UTC(year, month - 1, day));
if (
normalized.getUTCFullYear() !== year ||
normalized.getUTCMonth() + 1 !== month ||
normalized.getUTCDate() !== day
) {
return null;
}
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
const transactionsRoutes: FastifyPluginAsync<TransactionsRoutesOptions> = async (
app,
@@ -518,9 +551,15 @@ const transactionsRoutes: FastifyPluginAsync<TransactionsRoutesOptions> = async
if (typeof q === "string" && q.trim() !== "") {
const qTrim = q.trim();
const asCents = opts.parseCurrencyToCents(qTrim);
const asDate = parseDateSearchTerm(qTrim);
if (asCents > 0) {
flexibleOr.push({ amountCents: opts.toBig(asCents) });
}
if (asDate) {
flexibleOr.push({
occurredAt: getUserDateRangeFromDateOnly(userTimezone, asDate, asDate),
});
}
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } });
flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } });

View File

@@ -21,6 +21,8 @@ afterAll(async () => {
describe("GET /transactions", () => {
let catId: string;
let planId: string;
let tx1Id: string;
let tx2Id: string;
beforeEach(async () => {
await resetUser(U);
@@ -54,23 +56,28 @@ describe("GET /transactions", () => {
});
planId = plan.id;
tx1Id = `t_${Date.now()}_1`;
tx2Id = `t_${Date.now()}_2`;
await prisma.transaction.createMany({
data: [
{
id: `t_${Date.now()}_1`,
id: tx1Id,
userId: U,
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
kind: "variable_spend",
categoryId: catId,
amountCents: 1000n,
note: "Groceries pickup",
},
{
id: `t_${Date.now()}_2`,
id: tx2Id,
userId: U,
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
kind: "fixed_payment",
planId,
amountCents: 2000n,
note: "January rent",
},
],
});
@@ -103,6 +110,32 @@ describe("GET /transactions", () => {
expect(byPlan.status).toBe(200);
expect(byPlan.body.items.every((t: any) => t.planId === planId)).toBe(true);
});
it("searches by category, note, amount, and date", async () => {
const byCategory = await request(app.server)
.get("/transactions?q=grocer")
.set("x-user-id", U);
expect(byCategory.status).toBe(200);
expect(byCategory.body.items.some((t: any) => t.id === tx1Id)).toBe(true);
const byNote = await request(app.server)
.get("/transactions?q=pickup")
.set("x-user-id", U);
expect(byNote.status).toBe(200);
expect(byNote.body.items.some((t: any) => t.id === tx1Id)).toBe(true);
const byAmount = await request(app.server)
.get("/transactions?q=10.00")
.set("x-user-id", U);
expect(byAmount.status).toBe(200);
expect(byAmount.body.items.some((t: any) => t.id === tx1Id)).toBe(true);
const byDate = await request(app.server)
.get("/transactions?q=2025-01-03")
.set("x-user-id", U);
expect(byDate.status).toBe(200);
expect(byDate.body.items.some((t: any) => t.id === tx1Id)).toBe(true);
});
});
describe("POST /transactions", () => {

0
api/vitest Normal file
View File