diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts index 770d29d..a34a4de 100644 --- a/api/src/routes/dashboard.ts +++ b/api/src/routes/dashboard.ts @@ -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: { diff --git a/web/src/hooks/useDashboard.ts b/web/src/hooks/useDashboard.ts index bf3f34d..1636651 100644 --- a/web/src/hooks/useDashboard.ts +++ b/web/src/hooks/useDashboard.ts @@ -55,6 +55,16 @@ export type DashboardResponse = { incomeCents: 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 }>; savingsTargets: Array<{ 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({ - queryKey: ["dashboard"], - queryFn: () => apiGet("/dashboard"), + queryKey: ["dashboard", trendPage, trendMonths], + queryFn: () => + apiGet("/dashboard", { + trendPage, + trendMonths, + }), staleTime: 10_000, enabled, }); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 008d619..3530c34 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -22,8 +22,14 @@ import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowse export default function DashboardPage() { const session = useAuthSession(); 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, + { + trendPage, + trendMonths, + } ); const navigate = useNavigate(); const { push } = useToast(); @@ -479,6 +485,12 @@ export default function DashboardPage() { const hasCategories = data.variableCategories.length > 0; const hasPlans = data.fixedPlans.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 = data.user.displayName || @@ -887,12 +899,25 @@ export default function DashboardPage() { )} {activeTab === "trend" && ( -
- - +
+ {useTrendWindowPagination ? ( + setTrendPage((p) => p + 1)} + onNewer={() => setTrendPage((p) => Math.max(0, p - 1))} + /> + ) : null} +
+ + +
)}
@@ -1077,10 +1102,21 @@ export default function DashboardPage() { )} {activeTab === "trend" && (
+ {useTrendWindowPagination ? ( + setTrendPage((p) => p + 1)} + onNewer={() => setTrendPage((p) => Math.max(0, p - 1))} + /> + ) : null}
)} @@ -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 ( +
+ +
{label}
+ +
+ ); +} + function UpcomingPlanAlerts({ plans, userTimezone, @@ -1128,18 +1202,18 @@ function UpcomingPlanAlerts({ if (plans.length === 0) return null; return (
-
+
Upcoming plan alerts (next 14 days)
    {plans.map((plan) => (
  • {plan.name}
    -
    +
    Due{" "} {formatDateInTimezone(plan.dueOn, userTimezone, { month: "short", @@ -1148,7 +1222,7 @@ function UpcomingPlanAlerts({
    -
    Remaining
    +
    Remaining
  • @@ -1203,14 +1277,20 @@ function SavingsGoalsPanel({ function RecentTransactionsPanel({ transactions, hasData, + rangeLabel, }: { transactions: DashboardResponse["recentTransactions"]; hasData: boolean; + rangeLabel?: string; }) { + const title = rangeLabel ? "Transactions in window" : "Recent transactions"; + const visibleTransactions = transactions.slice(0, 10); + if (!hasData) { return (
    -

    Recent transactions

    +

    {title}

    + {rangeLabel ?
    {rangeLabel}
    : null} -

    Recent transactions

    +

    {title}

    + {rangeLabel ?
    {rangeLabel}
    : null} {/* Desktop table view */}
    @@ -1234,7 +1315,7 @@ function RecentTransactionsPanel({ - {transactions.map((tx) => ( + {visibleTransactions.map((tx) => ( {new Date(tx.occurredAt).toLocaleString()} @@ -1253,7 +1334,7 @@ function RecentTransactionsPanel({ {/* Mobile card view */}
    - {transactions.map((tx) => ( + {visibleTransactions.map((tx) => (
    @@ -1276,6 +1357,11 @@ function RecentTransactionsPanel({
    ))}
    +
    + + View more + +
    ); } diff --git a/web/src/pages/settings/RebalancePage.tsx b/web/src/pages/settings/RebalancePage.tsx index 05bf1ab..99cfd40 100644 --- a/web/src/pages/settings/RebalancePage.tsx +++ b/web/src/pages/settings/RebalancePage.tsx @@ -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 { rebalanceApi, type RebalanceCategory } from "../../api/rebalance"; import CurrencyInput from "../../components/CurrencyInput"; import { useToast } from "../../components/Toast"; +const REBALANCE_SCROLL_NUDGE_SEEN_KEY = "rebalance.scroll.nudge.v1"; + function sum(values: number[]) { return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0); } @@ -52,6 +54,8 @@ export default function RebalancePage() { const [adjustId, setAdjustId] = useState(""); const [adjustValue, setAdjustValue] = useState(""); const [confirmOpen, setConfirmOpen] = useState(false); + const tableScrollRef = useRef(null); + const [scrollCue, setScrollCue] = useState({ left: false, right: false }); useEffect(() => { if (data?.categories) { @@ -60,6 +64,66 @@ export default function RebalancePage() { } }, [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 total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]); const savingsTotal = useMemo( @@ -259,40 +323,61 @@ export default function RebalancePage() {
    -
    - - - - - - - - - - - {rows.map((row) => ( - - - - - +
    +
    +
    CategoryCurrentPercentTarget
    - {row.name} - {row.isSavings ? Savings : null} - ${(row.balanceCents / 100).toFixed(2)}{row.percent}% - - setRows((prev) => - prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r)) - ) - } - /> -
    + + + + + + - ))} - -
    CategoryCurrentPercentTarget
    + + + {rows.map((row) => ( + + + {row.name} + {row.isSavings ? Savings : null} + + ${(row.balanceCents / 100).toFixed(2)} + {row.percent}% + + + setRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r)) + ) + } + /> + + + ))} + + +
    + {scrollCue.left && ( +