Task: added pagination to records page, removed initial onboarding income from activity chart
This commit is contained in:
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"atlascode.jira.explorer.enabled": true,
|
||||||
|
"atlascode.jira.explorer.fetchAllQueryResults": true
|
||||||
|
}
|
||||||
0
api/skymoney-api@0.1.0
Normal file
0
api/skymoney-api@0.1.0
Normal file
@@ -3,6 +3,7 @@ import { getUserMidnightFromDateOnly } from "../allocator.js";
|
|||||||
import { getUserTimezone } from "../services/user-context.js";
|
import { getUserTimezone } from "../services/user-context.js";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const ONBOARDING_SEED_NOTE_MARKER = "[onboarding-seed]";
|
||||||
|
|
||||||
const monthKey = (date: Date) =>
|
const monthKey = (date: Date) =>
|
||||||
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
@@ -30,7 +31,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
|
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({
|
app.prisma.variableCategory.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||||
@@ -54,8 +55,16 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
_sum: { amountCents: true },
|
_sum: { amountCents: true },
|
||||||
}),
|
}),
|
||||||
app.prisma.incomeEvent.findMany({
|
app.prisma.incomeEvent.findMany({
|
||||||
where: { userId, postedAt: { gte: rangeStart } },
|
where: {
|
||||||
select: { postedAt: true, amountCents: true },
|
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({
|
app.prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -140,8 +149,17 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
};
|
};
|
||||||
const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0);
|
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>();
|
const incomeByMonth = new Map<string, number>();
|
||||||
incomeEvents.forEach((evt) => {
|
filteredIncomeEvents.forEach((evt) => {
|
||||||
const key = monthKey(evt.postedAt);
|
const key = monthKey(evt.postedAt);
|
||||||
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
|
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,39 @@ type TransactionsRoutesOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
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 (
|
const transactionsRoutes: FastifyPluginAsync<TransactionsRoutesOptions> = async (
|
||||||
app,
|
app,
|
||||||
@@ -518,9 +551,15 @@ const transactionsRoutes: FastifyPluginAsync<TransactionsRoutesOptions> = async
|
|||||||
if (typeof q === "string" && q.trim() !== "") {
|
if (typeof q === "string" && q.trim() !== "") {
|
||||||
const qTrim = q.trim();
|
const qTrim = q.trim();
|
||||||
const asCents = opts.parseCurrencyToCents(qTrim);
|
const asCents = opts.parseCurrencyToCents(qTrim);
|
||||||
|
const asDate = parseDateSearchTerm(qTrim);
|
||||||
if (asCents > 0) {
|
if (asCents > 0) {
|
||||||
flexibleOr.push({ amountCents: opts.toBig(asCents) });
|
flexibleOr.push({ amountCents: opts.toBig(asCents) });
|
||||||
}
|
}
|
||||||
|
if (asDate) {
|
||||||
|
flexibleOr.push({
|
||||||
|
occurredAt: getUserDateRangeFromDateOnly(userTimezone, asDate, asDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
|
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
|
||||||
flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } });
|
flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } });
|
||||||
flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } });
|
flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } });
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ afterAll(async () => {
|
|||||||
describe("GET /transactions", () => {
|
describe("GET /transactions", () => {
|
||||||
let catId: string;
|
let catId: string;
|
||||||
let planId: string;
|
let planId: string;
|
||||||
|
let tx1Id: string;
|
||||||
|
let tx2Id: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetUser(U);
|
await resetUser(U);
|
||||||
@@ -54,23 +56,28 @@ describe("GET /transactions", () => {
|
|||||||
});
|
});
|
||||||
planId = plan.id;
|
planId = plan.id;
|
||||||
|
|
||||||
|
tx1Id = `t_${Date.now()}_1`;
|
||||||
|
tx2Id = `t_${Date.now()}_2`;
|
||||||
|
|
||||||
await prisma.transaction.createMany({
|
await prisma.transaction.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: `t_${Date.now()}_1`,
|
id: tx1Id,
|
||||||
userId: U,
|
userId: U,
|
||||||
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
|
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
|
||||||
kind: "variable_spend",
|
kind: "variable_spend",
|
||||||
categoryId: catId,
|
categoryId: catId,
|
||||||
amountCents: 1000n,
|
amountCents: 1000n,
|
||||||
|
note: "Groceries pickup",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `t_${Date.now()}_2`,
|
id: tx2Id,
|
||||||
userId: U,
|
userId: U,
|
||||||
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
|
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
|
||||||
kind: "fixed_payment",
|
kind: "fixed_payment",
|
||||||
planId,
|
planId,
|
||||||
amountCents: 2000n,
|
amountCents: 2000n,
|
||||||
|
note: "January rent",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -103,6 +110,32 @@ describe("GET /transactions", () => {
|
|||||||
expect(byPlan.status).toBe(200);
|
expect(byPlan.status).toBe(200);
|
||||||
expect(byPlan.body.items.every((t: any) => t.planId === planId)).toBe(true);
|
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", () => {
|
describe("POST /transactions", () => {
|
||||||
|
|||||||
0
api/vitest
Normal file
0
api/vitest
Normal file
@@ -294,9 +294,6 @@ export default function IncomePage() {
|
|||||||
const payload: CreateIncomeInput = {
|
const payload: CreateIncomeInput = {
|
||||||
amountCents,
|
amountCents,
|
||||||
};
|
};
|
||||||
if (debugNowISO) {
|
|
||||||
payload.occurredAtISO = debugNowISO;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedNote = notes.trim();
|
const trimmedNote = notes.trim();
|
||||||
if (trimmedNote) {
|
if (trimmedNote) {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const defaultSchedule = (): AutoPaySchedule => ({
|
|||||||
});
|
});
|
||||||
const DEFAULT_SAVINGS_PERCENT = 20;
|
const DEFAULT_SAVINGS_PERCENT = 20;
|
||||||
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
|
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
|
||||||
|
const ONBOARDING_SEED_NOTE_MARKER = "[onboarding-seed]";
|
||||||
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
|
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
|
||||||
const normalizePercentValue = (value: unknown) => {
|
const normalizePercentValue = (value: unknown) => {
|
||||||
const parsed =
|
const parsed =
|
||||||
@@ -476,7 +477,10 @@ export default function OnboardingPage() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Use regular income allocation for regular income
|
// Use regular income allocation for regular income
|
||||||
await apiPost("/income", { amountCents: budgetCents });
|
await apiPost("/income", {
|
||||||
|
amountCents: budgetCents,
|
||||||
|
note: `Initial budget setup ${ONBOARDING_SEED_NOTE_MARKER}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function TransactionsPage() {
|
|||||||
const [catFilter, setCatFilter] = useState<string>("all");
|
const [catFilter, setCatFilter] = useState<string>("all");
|
||||||
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
|
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 100;
|
const limit = 10;
|
||||||
|
|
||||||
// Get current date in user's timezone
|
// Get current date in user's timezone
|
||||||
const todayStr = getTodayInTimezone(userTimezone);
|
const todayStr = getTodayInTimezone(userTimezone);
|
||||||
@@ -53,14 +53,16 @@ export default function TransactionsPage() {
|
|||||||
const total = txQuery.data?.total ?? 0;
|
const total = txQuery.data?.total ?? 0;
|
||||||
|
|
||||||
const catOptions = useMemo(() => {
|
const catOptions = useMemo(() => {
|
||||||
if (transactions.length === 0) return [];
|
const categoryOptions = (dashboard?.variableCategories ?? []).map((c) => ({
|
||||||
const buckets = new Map<string, string>();
|
id: c.id,
|
||||||
transactions.forEach((t) => {
|
name: c.name,
|
||||||
if (t.categoryId && t.categoryName) buckets.set(t.categoryId, t.categoryName);
|
}));
|
||||||
if (t.planId && t.planName) buckets.set(t.planId, t.planName);
|
const planOptions = (dashboard?.fixedPlans ?? []).map((p) => ({
|
||||||
});
|
id: p.id,
|
||||||
return Array.from(buckets.entries()).map(([id, name]) => ({ id, name }));
|
name: p.name,
|
||||||
}, [transactions]);
|
}));
|
||||||
|
return [...categoryOptions, ...planOptions];
|
||||||
|
}, [dashboard?.variableCategories, dashboard?.fixedPlans]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ export default function TransactionsPage() {
|
|||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<input
|
<input
|
||||||
className="input w-full sm:w-56"
|
className="input w-full sm:w-56"
|
||||||
placeholder="Search..."
|
placeholder="Search category, note, amount, or date..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
|
|||||||
Reference in New Issue
Block a user