11 Commits

Author SHA1 Message Date
1ffb909c57 changed records page UI to be better (shows first 10 by default
All checks were successful
Deploy / deploy (push) Successful in 57s
Security Tests / security-non-db (push) Successful in 27s
Security Tests / security-db (push) Successful in 32s
2026-04-02 22:29:09 -05:00
46eb54f341 build is fr good now
All checks were successful
Deploy / deploy (push) Successful in 59s
Security Tests / security-non-db (push) Successful in 23s
Security Tests / security-db (push) Successful in 32s
2026-04-02 22:21:56 -05:00
e51b9a939c wweb build error fixed for last push
Some checks failed
Deploy / deploy (push) Failing after 33s
Security Tests / security-non-db (push) Successful in 23s
Security Tests / security-db (push) Successful in 32s
2026-04-02 22:18:43 -05:00
854528701e moved tip to records page for month reference
Some checks failed
Deploy / deploy (push) Failing after 32s
Security Tests / security-non-db (push) Successful in 24s
Security Tests / security-db (push) Successful in 31s
2026-04-02 22:16:34 -05:00
47bc092da1 first attempt at fixing over-allocation bug; fix npm audit block for deploy
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 23s
Security Tests / security-db (push) Successful in 33s
2026-04-02 22:06:35 -05:00
b3d32c08e9 commited last commit from api directory on accident
Some checks failed
Deploy / deploy (push) Successful in 2m12s
Security Tests / security-non-db (push) Failing after 25s
Security Tests / security-db (push) Failing after 33s
2026-04-02 21:57:28 -05:00
1eda007d8b fix deploy to skip critical for no-risk critical npm warning (fastjwt via fastify)
Some checks failed
Deploy / deploy (push) Failing after 15s
Security Tests / security-non-db (push) Successful in 24s
Security Tests / security-db (push) Successful in 32s
2026-04-02 21:53:52 -05:00
48268728f8 add ui tip in records for current month
Some checks failed
Deploy / deploy (push) Failing after 14s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
2026-04-02 21:40:09 -05:00
6f27f9117a fix: fixed supressed payady overlay in dashboard get response
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 26s
2026-03-28 21:45:31 -05:00
339ab559d1 fix: made payday overlay only go away when iuncome detected
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 22s
Security Tests / security-db (push) Successful in 27s
2026-03-28 21:37:36 -05:00
cb87c906c8 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
2026-03-25 23:27:59 -05:00
11 changed files with 1811 additions and 1056 deletions

View File

@@ -15,7 +15,36 @@ jobs:
set -euo pipefail set -euo pipefail
cd api cd api
npm ci npm ci
npm audit --omit=dev --audit-level=high npm audit --omit=dev --audit-level=high --json > /tmp/skymoney-api-audit.json || true
node -e '
const fs = require("fs");
const report = JSON.parse(fs.readFileSync("/tmp/skymoney-api-audit.json", "utf8"));
const vulnerabilities = report.vulnerabilities || {};
const allowlisted = new Set(["fast-jwt", "@fastify/jwt"]);
const blockers = [];
for (const [name, vuln] of Object.entries(vulnerabilities)) {
const severity = String(vuln?.severity || "").toLowerCase();
if (severity !== "high" && severity !== "critical") continue;
if (allowlisted.has(name)) continue;
blockers.push({ name, severity });
}
if (blockers.length > 0) {
console.error("Blocking high/critical vulnerabilities found:");
for (const blocker of blockers) {
console.error(` - ${blocker.name} (${blocker.severity})`);
}
process.exit(1);
}
const allowedPresent = Object.keys(vulnerabilities).filter((name) => allowlisted.has(name));
if (allowedPresent.length > 0) {
console.warn("Allowed advisory exception(s) present:", allowedPresent.join(", "));
} else {
console.log("No allowlisted API advisories present.");
}
'
cd ../web cd ../web
npm ci npm ci
npm audit --omit=dev --audit-level=high npm audit --omit=dev --audit-level=high

2330
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,12 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.19.25", "@types/node": "^20.19.25",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/supertest": "^6.0.3", "@types/supertest": "^7.2.0",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"supertest": "^6.3.4", "supertest": "^7.2.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vitest": "^2.1.3" "vitest": "^4.1.2"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
@@ -38,7 +38,7 @@
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^7.0.13", "nodemailer": "^8.0.4",
"zod": "^3.23.8" "zod": "^3.23.8"
} }
} }

View File

@@ -778,8 +778,20 @@ async function applyAllocations(
): Promise<Array<{id: string, name: string, dueOn: Date}>> { ): Promise<Array<{id: string, name: string, dueOn: Date}>> {
// Fixed plans // Fixed plans
const planUpdates = new Map<string, number>(); const planUpdates = new Map<string, number>();
const fixedAllocationRows = new Map<string, { planId: string; source: FixedAllocation["source"]; amountCents: number }>();
result.fixedAllocations.forEach((a) => { result.fixedAllocations.forEach((a) => {
planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents); planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents);
const rowKey = `${a.fixedPlanId}:${a.source}`;
const existing = fixedAllocationRows.get(rowKey);
if (existing) {
existing.amountCents += a.amountCents;
return;
}
fixedAllocationRows.set(rowKey, {
planId: a.fixedPlanId,
source: a.source,
amountCents: a.amountCents,
});
}); });
const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = []; const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = [];
@@ -834,13 +846,19 @@ async function applyAllocations(
needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded, needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded,
}, },
}); });
}
for (const row of fixedAllocationRows.values()) {
const rowAmount = Math.max(0, Math.floor(row.amountCents | 0));
if (rowAmount <= 0) continue;
await tx.allocation.create({ await tx.allocation.create({
data: { data: {
userId, userId,
kind: "fixed", kind: "fixed",
toId: planId, toId: row.planId,
amountCents: BigInt(amt), amountCents: BigInt(rowAmount),
incomeId, // Available-budget pulls must not be attributed to the triggering income event.
incomeId: row.source === "income" ? incomeId : null,
}, },
}); });
} }

View File

@@ -1,4 +1,5 @@
import type { FastifyPluginAsync } from "fastify"; import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { getUserMidnightFromDateOnly } from "../allocator.js"; import { getUserMidnightFromDateOnly } from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js"; import { getUserTimezone } from "../services/user-context.js";
@@ -11,9 +12,11 @@ const monthKey = (date: Date) =>
const monthLabel = (date: Date) => const monthLabel = (date: Date) =>
date.toLocaleString("en-US", { month: "short", year: "numeric" }); 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 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--) { for (let i = count - 1; i >= 0; i--) {
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1)); const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 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) => { 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 userId = req.userId;
const monthsBack = 6; const { trendPage, trendMonths } = parsedQuery.data;
const buckets = buildMonthBuckets(monthsBack);
const rangeStart = buckets[0]?.start ?? new Date();
const now = new Date(); 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 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({ app.prisma.variableCategory.findMany({
where: { userId }, where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }], orderBy: [{ priority: "asc" }, { name: "asc" }],
@@ -43,7 +59,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.prisma.transaction.findMany({ app.prisma.transaction.findMany({
where: { userId, kind: { in: dashboardTxKinds } }, where: { userId, kind: { in: dashboardTxKinds } },
orderBy: { occurredAt: "desc" }, orderBy: { occurredAt: "desc" },
take: 50, take: 20,
select: { id: true, kind: true, amountCents: true, occurredAt: true }, select: { id: true, kind: true, amountCents: true, occurredAt: true },
}), }),
app.prisma.incomeEvent.aggregate({ app.prisma.incomeEvent.aggregate({
@@ -57,7 +73,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.prisma.incomeEvent.findMany({ app.prisma.incomeEvent.findMany({
where: { where: {
userId, userId,
postedAt: { gte: rangeStart }, postedAt: { gte: rangeStart, lt: rangeEnd },
}, },
select: { id: true, postedAt: true, amountCents: true, note: true }, select: { id: true, postedAt: true, amountCents: true, note: true },
}), }),
@@ -70,10 +86,18 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
where: { where: {
userId, userId,
kind: { in: dashboardTxKinds }, kind: { in: dashboardTxKinds },
occurredAt: { gte: rangeStart }, occurredAt: { gte: rangeStart, lt: rangeEnd },
}, },
select: { occurredAt: true, amountCents: true }, select: { occurredAt: true, amountCents: true },
}), }),
app.prisma.transaction.findFirst({
where: {
userId,
kind: { in: dashboardTxKinds },
},
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
}),
app.prisma.user.findUnique({ app.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { select: {
@@ -174,7 +198,21 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
incomeCents: incomeByMonth.get(bucket.key) ?? 0, incomeCents: incomeByMonth.get(bucket.key) ?? 0,
spendCents: spendByMonth.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 const upcomingPlans = fixedPlans
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) })) .map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
.filter( .filter(
@@ -279,6 +317,16 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40, fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
}, },
monthlyTrend, monthlyTrend,
trendWindow: {
page: trendPage,
months: trendMonths,
canGoNewer: trendPage > 0,
canGoOlder: hasOlderTrendWindow,
hasTransactionsOlderThanSixMonths,
startLabel,
endLabel,
label: rangeLabel,
},
upcomingPlans, upcomingPlans,
savingsTargets, savingsTargets,
crisis: { crisis: {

View File

@@ -4,6 +4,8 @@ import { z } from "zod";
import { fromZonedTime } from "date-fns-tz"; import { fromZonedTime } from "date-fns-tz";
import { getUserMidnight } from "../allocator.js"; import { getUserMidnight } from "../allocator.js";
const DAY_MS = 24 * 60 * 60 * 1000;
type RateLimitRouteOptions = { type RateLimitRouteOptions = {
config: { config: {
rateLimit: { rateLimit: {
@@ -100,15 +102,43 @@ const paydayRoutes: FastifyPluginAsync<PaydayRoutesOptions> = async (app, opts)
userTimezone userTimezone
); );
const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone); const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone);
const dayStart = getUserMidnight(userTimezone, now);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1); // Determine the currently due pay cycle:
const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({ // - on payday: cycle starts today
// - after payday: cycle starts on the immediately prior expected payday
const oneCycleLookbackDaysByFrequency = {
weekly: 7,
biweekly: 14,
monthly: 32,
} as const;
const previousPaydayAnchor = new Date(
nextPayday.getTime() - oneCycleLookbackDaysByFrequency[user.incomeFrequency] * DAY_MS
);
const previousExpectedPayday = calculateNextPayday(
user.firstIncomeDate,
user.incomeFrequency,
previousPaydayAnchor,
userTimezone
);
const currentCycleStart = isPayday ? nextPayday : previousExpectedPayday;
const isCycleDue = now.getTime() >= currentCycleStart.getTime();
const currentCycleEnd =
currentCycleStart.getTime() === nextPayday.getTime()
? calculateNextPayday(
user.firstIncomeDate,
user.incomeFrequency,
new Date(nextPayday.getTime() + DAY_MS),
userTimezone
)
: nextPayday;
const scheduledIncomeInCurrentCycle = await app.prisma.incomeEvent.findFirst({
where: { where: {
userId, userId,
isScheduledIncome: true, isScheduledIncome: true,
postedAt: { postedAt: {
gte: dayStart, gte: currentCycleStart,
lte: dayEnd, lt: currentCycleEnd,
}, },
}, },
select: { id: true }, select: { id: true },
@@ -120,14 +150,17 @@ const paydayRoutes: FastifyPluginAsync<PaydayRoutesOptions> = async (app, opts)
firstIncomeDate: user.firstIncomeDate.toISOString(), firstIncomeDate: user.firstIncomeDate.toISOString(),
nextPayday: nextPayday.toISOString(), nextPayday: nextPayday.toISOString(),
isPayday, isPayday,
currentCycleStart: currentCycleStart.toISOString(),
currentCycleEnd: currentCycleEnd.toISOString(),
isCycleDue,
pendingScheduledIncome: user.pendingScheduledIncome, pendingScheduledIncome: user.pendingScheduledIncome,
scheduledIncomeToday: !!scheduledIncomeToday, scheduledIncomeInCurrentCycle: !!scheduledIncomeInCurrentCycle,
shouldShowOverlay: isPayday && !scheduledIncomeToday, shouldShowOverlay: isCycleDue && !scheduledIncomeInCurrentCycle,
}); });
return { return {
shouldShowOverlay: isPayday && !scheduledIncomeToday, shouldShowOverlay: isCycleDue && !scheduledIncomeInCurrentCycle,
pendingScheduledIncome: !scheduledIncomeToday, pendingScheduledIncome: !scheduledIncomeInCurrentCycle,
nextPayday: nextPayday.toISOString(), nextPayday: nextPayday.toISOString(),
}; };
}); });

View File

@@ -3,6 +3,8 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import { allocateIncome, buildPlanStates } from "../src/allocator"; import { allocateIncome, buildPlanStates } from "../src/allocator";
const DAY_MS = 86_400_000;
describe("allocator — new funding system", () => { describe("allocator — new funding system", () => {
beforeEach(async () => { beforeEach(async () => {
await resetUser(U); await resetUser(U);
@@ -228,5 +230,58 @@ describe("allocator — new funding system", () => {
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0); const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
expect(sum).toBe(0); expect(sum).toBe(0);
}); });
it("does not over-attribute crisis available-budget pulls to the triggering income event", async () => {
const c1 = cid("bucket");
await prisma.variableCategory.create({
data: { id: c1, userId: U, name: "Bucket", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const planId = pid("crisis");
await prisma.fixedPlan.create({
data: {
id: planId,
userId: U,
name: "Urgent Rent",
cycleStart: new Date(Date.now() - DAY_MS).toISOString(),
dueOn: new Date(Date.now() + 3 * DAY_MS).toISOString(),
totalCents: 40000n,
fundedCents: 0n,
currentFundedCents: 0n,
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
// Seed existing available budget from prior income that was not fully allocated.
await prisma.incomeEvent.create({
data: {
id: `seed-${Date.now()}`,
userId: U,
postedAt: new Date(Date.now() - 10 * DAY_MS),
amountCents: 50000n,
isScheduledIncome: false,
},
});
const incomeId = `inc-${Date.now()}`;
const result = await allocateIncome(prisma as any, U, 1000, new Date().toISOString(), incomeId);
expect(result.crisis.pulledFromAvailableCents).toBeGreaterThan(0);
const linkedToIncome = await prisma.allocation.findMany({
where: { userId: U, incomeId },
select: { amountCents: true, kind: true },
});
const linkedSum = linkedToIncome.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0);
expect(linkedSum).toBeLessThanOrEqual(1000);
const unlinkedFixed = await prisma.allocation.findMany({
where: { userId: U, incomeId: null, kind: "fixed" },
select: { amountCents: true },
});
const unlinkedFixedSum = unlinkedFixed.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0);
expect(unlinkedFixedSum).toBeGreaterThan(0);
});
}); });
}); });

View File

@@ -55,6 +55,16 @@ export type DashboardResponse = {
incomeCents: number; incomeCents: number;
spendCents: number; spendCents: number;
}>; }>;
trendWindow?: {
page: number;
months: number;
canGoNewer: boolean;
canGoOlder: boolean;
hasTransactionsOlderThanSixMonths: boolean;
startLabel: string;
endLabel: string;
label: string;
};
upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>; upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>;
savingsTargets: Array<{ savingsTargets: Array<{
id: string; id: string;
@@ -79,10 +89,22 @@ export type DashboardResponse = {
}; };
}; };
export function useDashboard(enabled = true) { export type DashboardQueryOptions = {
trendPage?: number;
trendMonths?: number;
};
export function useDashboard(enabled = true, options?: DashboardQueryOptions) {
const trendPage = Math.max(0, options?.trendPage ?? 0);
const trendMonths = Math.min(12, Math.max(1, options?.trendMonths ?? 6));
return useQuery({ return useQuery({
queryKey: ["dashboard"], queryKey: ["dashboard", trendPage, trendMonths],
queryFn: () => apiGet<DashboardResponse>("/dashboard"), queryFn: () =>
apiGet<DashboardResponse>("/dashboard", {
trendPage,
trendMonths,
}),
staleTime: 10_000, staleTime: 10_000,
enabled, enabled,
}); });

View File

@@ -22,8 +22,14 @@ import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowse
export default function DashboardPage() { export default function DashboardPage() {
const session = useAuthSession(); const session = useAuthSession();
const shouldLoadDashboard = !!session.data?.userId; const shouldLoadDashboard = !!session.data?.userId;
const { data, isLoading, isError, error, refetch } = useDashboard( const trendMonths = 6;
const [trendPage, setTrendPage] = useState(0);
const { data, isLoading, isError, isFetching, error, refetch } = useDashboard(
shouldLoadDashboard, shouldLoadDashboard,
{
trendPage,
trendMonths,
}
); );
const navigate = useNavigate(); const navigate = useNavigate();
const { push } = useToast(); const { push } = useToast();
@@ -479,6 +485,12 @@ export default function DashboardPage() {
const hasCategories = data.variableCategories.length > 0; const hasCategories = data.variableCategories.length > 0;
const hasPlans = data.fixedPlans.length > 0; const hasPlans = data.fixedPlans.length > 0;
const hasTx = data.recentTransactions.length > 0; const hasTx = data.recentTransactions.length > 0;
const hasTransactionsOlderThanSixMonths =
data.trendWindow?.hasTransactionsOlderThanSixMonths ?? false;
const useTrendWindowPagination = hasTransactionsOlderThanSixMonths;
const trendWindowLabel = data.trendWindow?.label ?? "Last 6 months";
const canTrendNewer = data.trendWindow?.canGoNewer ?? trendPage > 0;
const canTrendOlder = data.trendWindow?.canGoOlder ?? true;
const greetingName = const greetingName =
data.user.displayName || data.user.displayName ||
@@ -887,12 +899,25 @@ export default function DashboardPage() {
)} )}
{activeTab === "trend" && ( {activeTab === "trend" && (
<section className="grid gap-6 lg:grid-cols-2"> <section className="space-y-4">
<MonthlyTrendChart data={data.monthlyTrend} /> {useTrendWindowPagination ? (
<RecentTransactionsPanel <TrendWindowControls
transactions={data.recentTransactions} label={trendWindowLabel}
hasData={hasTx} canGoOlder={canTrendOlder}
/> canGoNewer={canTrendNewer}
busy={isFetching}
onOlder={() => setTrendPage((p) => p + 1)}
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
/>
) : null}
<section className="grid gap-6 lg:grid-cols-2">
<MonthlyTrendChart data={data.monthlyTrend} />
<RecentTransactionsPanel
transactions={data.recentTransactions}
hasData={hasTx}
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
/>
</section>
</section> </section>
)} )}
</section> </section>
@@ -1077,10 +1102,21 @@ export default function DashboardPage() {
)} )}
{activeTab === "trend" && ( {activeTab === "trend" && (
<section className="space-y-4"> <section className="space-y-4">
{useTrendWindowPagination ? (
<TrendWindowControls
label={trendWindowLabel}
canGoOlder={canTrendOlder}
canGoNewer={canTrendNewer}
busy={isFetching}
onOlder={() => setTrendPage((p) => p + 1)}
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
/>
) : null}
<MonthlyTrendChart data={data.monthlyTrend} /> <MonthlyTrendChart data={data.monthlyTrend} />
<RecentTransactionsPanel <RecentTransactionsPanel
transactions={data.recentTransactions} transactions={data.recentTransactions}
hasData={hasTx} hasData={hasTx}
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
/> />
</section> </section>
)} )}
@@ -1118,6 +1154,44 @@ function AnalyticsTabButton({
); );
} }
function TrendWindowControls({
label,
canGoOlder,
canGoNewer,
busy,
onOlder,
onNewer,
}: {
label: string;
canGoOlder: boolean;
canGoNewer: boolean;
busy: boolean;
onOlder: () => void;
onNewer: () => void;
}) {
return (
<div className="row items-center gap-2 rounded-xl border px-3 py-2 bg-[--color-panel]">
<button
type="button"
className="btn"
onClick={onNewer}
disabled={!canGoNewer || busy}
>
Newer
</button>
<div className="text-sm muted ml-1">{label}</div>
<button
type="button"
className="btn ml-auto"
onClick={onOlder}
disabled={!canGoOlder || busy}
>
Older
</button>
</div>
);
}
function UpcomingPlanAlerts({ function UpcomingPlanAlerts({
plans, plans,
userTimezone, userTimezone,
@@ -1128,18 +1202,18 @@ function UpcomingPlanAlerts({
if (plans.length === 0) return null; if (plans.length === 0) return null;
return ( return (
<section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10"> <section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100"> <div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-50">
Upcoming plan alerts (next 14 days) Upcoming plan alerts (next 14 days)
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{plans.map((plan) => ( {plans.map((plan) => (
<li <li
key={plan.id} key={plan.id}
className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-[--color-ink]/20 dark:text-[--color-text] sm:flex-row sm:items-center sm:justify-between" className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-200/15 dark:text-amber-50 sm:flex-row sm:items-center sm:justify-between"
> >
<div> <div>
<div className="font-medium text-amber-950 dark:text-white">{plan.name}</div> <div className="font-medium text-amber-950 dark:text-white">{plan.name}</div>
<div className="text-xs muted"> <div className="text-xs text-amber-700 dark:text-amber-200">
Due{" "} Due{" "}
{formatDateInTimezone(plan.dueOn, userTimezone, { {formatDateInTimezone(plan.dueOn, userTimezone, {
month: "short", month: "short",
@@ -1148,7 +1222,7 @@ function UpcomingPlanAlerts({
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-xs muted">Remaining</div> <div className="text-xs text-amber-700 dark:text-amber-200">Remaining</div>
<Money cents={plan.remainingCents} /> <Money cents={plan.remainingCents} />
</div> </div>
</li> </li>
@@ -1203,14 +1277,19 @@ function SavingsGoalsPanel({
function RecentTransactionsPanel({ function RecentTransactionsPanel({
transactions, transactions,
hasData, hasData,
rangeLabel,
}: { }: {
transactions: DashboardResponse["recentTransactions"]; transactions: DashboardResponse["recentTransactions"];
hasData: boolean; hasData: boolean;
rangeLabel?: string;
}) { }) {
const title = rangeLabel ? "Transactions in window" : "Recent transactions";
const visibleTransactions = transactions.slice(0, 10);
if (!hasData) { if (!hasData) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h2 className="font-semibold">Recent transactions</h2> <h2 className="font-semibold">{title}</h2>
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
<EmptyState <EmptyState
message="No transactions yet" message="No transactions yet"
actionLabel="Record one" actionLabel="Record one"
@@ -1221,7 +1300,8 @@ function RecentTransactionsPanel({
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h2 className="font-semibold">Recent transactions</h2> <h2 className="font-semibold">{title}</h2>
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
{/* Desktop table view */} {/* Desktop table view */}
<div className="hidden sm:block border rounded-xl overflow-x-auto"> <div className="hidden sm:block border rounded-xl overflow-x-auto">
@@ -1234,7 +1314,7 @@ function RecentTransactionsPanel({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{transactions.map((tx) => ( {visibleTransactions.map((tx) => (
<tr key={tx.id} className="border-t"> <tr key={tx.id} className="border-t">
<td className="p-2"> <td className="p-2">
{new Date(tx.occurredAt).toLocaleString()} {new Date(tx.occurredAt).toLocaleString()}
@@ -1253,7 +1333,7 @@ function RecentTransactionsPanel({
{/* Mobile card view */} {/* Mobile card view */}
<div className="sm:hidden space-y-2"> <div className="sm:hidden space-y-2">
{transactions.map((tx) => ( {visibleTransactions.map((tx) => (
<div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3"> <div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -1276,6 +1356,11 @@ function RecentTransactionsPanel({
</div> </div>
))} ))}
</div> </div>
<div>
<Link to="/transactions" className="btn text-sm">
View more
</Link>
</div>
</div> </div>
); );
} }

View File

@@ -13,7 +13,7 @@ export default function TransactionsPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all"); const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
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">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = 10; const limit = 10;

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance"; import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance";
import CurrencyInput from "../../components/CurrencyInput"; import CurrencyInput from "../../components/CurrencyInput";
import { useToast } from "../../components/Toast"; import { useToast } from "../../components/Toast";
const REBALANCE_SCROLL_NUDGE_SEEN_KEY = "rebalance.scroll.nudge.v1";
function sum(values: number[]) { function sum(values: number[]) {
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0); return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
} }
@@ -52,6 +54,8 @@ export default function RebalancePage() {
const [adjustId, setAdjustId] = useState<string>(""); const [adjustId, setAdjustId] = useState<string>("");
const [adjustValue, setAdjustValue] = useState<string>(""); const [adjustValue, setAdjustValue] = useState<string>("");
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const tableScrollRef = useRef<HTMLDivElement | null>(null);
const [scrollCue, setScrollCue] = useState({ left: false, right: false });
useEffect(() => { useEffect(() => {
if (data?.categories) { if (data?.categories) {
@@ -60,6 +64,66 @@ export default function RebalancePage() {
} }
}, [data?.categories]); }, [data?.categories]);
useEffect(() => {
if (typeof window === "undefined") return;
if (window.localStorage.getItem(REBALANCE_SCROLL_NUDGE_SEEN_KEY) === "1") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const el = tableScrollRef.current;
if (!el) return;
let backTimer: number | null = null;
const raf = window.requestAnimationFrame(() => {
const hasHorizontalOverflow = el.scrollWidth > el.clientWidth + 8;
if (!hasHorizontalOverflow) return;
const nudgeDistance = Math.min(64, Math.max(24, Math.round(el.clientWidth * 0.2)));
const startLeft = el.scrollLeft;
el.scrollTo({ left: startLeft + nudgeDistance, behavior: "smooth" });
backTimer = window.setTimeout(() => {
el.scrollTo({ left: startLeft, behavior: "smooth" });
}, 700);
window.localStorage.setItem(REBALANCE_SCROLL_NUDGE_SEEN_KEY, "1");
});
return () => {
window.cancelAnimationFrame(raf);
if (backTimer !== null) window.clearTimeout(backTimer);
};
}, [rows.length]);
useEffect(() => {
if (typeof window === "undefined") return;
const el = tableScrollRef.current;
if (!el) return;
let rafId = 0;
const updateCue = () => {
const hasOverflow = el.scrollWidth > el.clientWidth + 8;
if (!hasOverflow) {
setScrollCue((prev) => (prev.left || prev.right ? { left: false, right: false } : prev));
return;
}
const left = el.scrollLeft > 4;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
setScrollCue((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
};
const handleScrollOrResize = () => {
if (rafId) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(updateCue);
};
updateCue();
el.addEventListener("scroll", handleScrollOrResize, { passive: true });
window.addEventListener("resize", handleScrollOrResize);
return () => {
if (rafId) window.cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScrollOrResize);
window.removeEventListener("resize", handleScrollOrResize);
};
}, [rows.length]);
const available = data?.availableCents ?? 0; const available = data?.availableCents ?? 0;
const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]); const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]);
const savingsTotal = useMemo( const savingsTotal = useMemo(
@@ -259,40 +323,61 @@ export default function RebalancePage() {
</button> </button>
</div> </div>
<div className="overflow-auto rounded-2xl border border-[--color-border]/50 bg-[--color-panel]"> <div className="relative">
<table className="w-full text-sm"> <div
<thead className="bg-[--color-surface]"> ref={tableScrollRef}
<tr className="text-left"> className="overflow-auto rounded-2xl border border-[--color-border]/50 bg-[--color-panel]"
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Category</th> >
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Current</th> <table className="w-full text-sm">
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Percent</th> <thead className="bg-[--color-surface]">
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Target</th> <tr className="text-left">
</tr> <th className="py-3 px-4 text-xs uppercase tracking-wide muted">Category</th>
</thead> <th className="py-3 px-4 text-xs uppercase tracking-wide muted">Current</th>
<tbody> <th className="py-3 px-4 text-xs uppercase tracking-wide muted">Percent</th>
{rows.map((row) => ( <th className="py-3 px-4 text-xs uppercase tracking-wide muted">Target</th>
<tr key={row.id} className="border-t border-[--color-border]/30">
<td className="py-3 px-4 font-medium flex items-center gap-2">
{row.name}
{row.isSavings ? <span className="badge badge-ghost">Savings</span> : null}
</td>
<td className="py-3 px-4 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
<td className="py-3 px-4">{row.percent}%</td>
<td className="py-3 px-4">
<CurrencyInput
className="input w-32"
valueCents={row.targetCents}
onChange={(cents) =>
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
)
}
/>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {rows.map((row) => (
<tr key={row.id} className="border-t border-[--color-border]/30">
<td className="py-3 px-4 font-medium flex items-center gap-2">
{row.name}
{row.isSavings ? <span className="badge badge-ghost">Savings</span> : null}
</td>
<td className="py-3 px-4 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
<td className="py-3 px-4">{row.percent}%</td>
<td className="py-3 px-4">
<CurrencyInput
className="input w-32"
valueCents={row.targetCents}
onChange={(cents) =>
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
)
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{scrollCue.left && (
<div
className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[--color-panel] to-transparent"
aria-hidden="true"
/>
)}
{scrollCue.right && (
<div
className="pointer-events-none absolute inset-y-0 right-0 flex w-12 items-center justify-end bg-gradient-to-l from-[--color-panel] to-transparent pr-1"
aria-hidden="true"
>
<span className="rounded border border-[--color-border] bg-[--color-bg]/80 px-1.5 py-0.5 text-[11px] font-semibold text-[--color-muted]">
</span>
</div>
)}
</div> </div>
</div> </div>