task: added side scroll tip for rebalance table, added limitations to transaction table in recent activity chart (dashbaord)
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,13 +899,26 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "trend" && (
|
{activeTab === "trend" && (
|
||||||
|
<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}
|
||||||
<section className="grid gap-6 lg:grid-cols-2">
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
<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>
|
||||||
|
</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,20 @@ 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 +1301,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 +1315,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 +1334,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 +1357,11 @@ function RecentTransactionsPanel({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link to="/records" className="btn text-sm">
|
||||||
|
View more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +323,11 @@ 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">
|
||||||
|
<div
|
||||||
|
ref={tableScrollRef}
|
||||||
|
className="overflow-auto rounded-2xl border border-[--color-border]/50 bg-[--color-panel]"
|
||||||
|
>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[--color-surface]">
|
<thead className="bg-[--color-surface]">
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
@@ -294,6 +362,23 @@ export default function RebalancePage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user