upgrade navigation and route prefetch responsiveness

This commit is contained in:
2026-03-01 20:45:08 -05:00
parent d6895f185f
commit dc84f34fe9
17 changed files with 1208 additions and 142 deletions

View File

@@ -1,7 +1,8 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
import { BrainCircuit, Plus, RefreshCcw, Trash2 } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
@@ -12,16 +13,19 @@ import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
deleteHolding,
getLatestPortfolioInsight,
getTask,
getPortfolioSummary,
listHoldings,
queuePortfolioInsights,
queuePriceRefresh,
upsertHolding
} from '@/lib/api';
import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import {
holdingsQueryOptions,
latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions,
taskQueryOptions
} from '@/lib/query/options';
type FormState = {
ticker: string;
@@ -31,6 +35,11 @@ type FormState = {
};
const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c'];
const CHART_TEXT = '#e8fff8';
const CHART_MUTED = '#b4ced9';
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
const EMPTY_SUMMARY: PortfolioSummary = {
positions: 0,
@@ -42,6 +51,7 @@ const EMPTY_SUMMARY: PortfolioSummary = {
export default function PortfolioPage() {
const { isPending, isAuthenticated } = useAuthGuard();
const queryClient = useQueryClient();
const [holdings, setHoldings] = useState<Holding[]>([]);
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
@@ -52,14 +62,21 @@ export default function PortfolioPage() {
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
const loadPortfolio = useCallback(async () => {
setLoading(true);
const holdingsOptions = holdingsQueryOptions();
const summaryOptions = portfolioSummaryQueryOptions();
const insightOptions = latestPortfolioInsightQueryOptions();
if (!queryClient.getQueryData(summaryOptions.queryKey)) {
setLoading(true);
}
setError(null);
try {
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
listHoldings(),
getPortfolioSummary(),
getLatestPortfolioInsight()
queryClient.ensureQueryData(holdingsOptions),
queryClient.ensureQueryData(summaryOptions),
queryClient.ensureQueryData(insightOptions)
]);
setHoldings(holdingsRes.holdings);
@@ -70,7 +87,7 @@ export default function PortfolioPage() {
} finally {
setLoading(false);
}
}, []);
}, [queryClient]);
useEffect(() => {
if (!isPending && isAuthenticated) {
@@ -82,6 +99,10 @@ export default function PortfolioPage() {
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
await loadPortfolio();
}
});
@@ -116,6 +137,8 @@ export default function PortfolioPage() {
});
setForm({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save holding');
@@ -125,8 +148,10 @@ export default function PortfolioPage() {
const queueRefresh = async () => {
try {
const { task } = await queuePriceRefresh();
const latest = await getTask(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
}
@@ -135,8 +160,10 @@ export default function PortfolioPage() {
const queueInsights = async () => {
try {
const { task } = await queuePortfolioInsights();
const latest = await getTask(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
}
@@ -148,7 +175,7 @@ export default function PortfolioPage() {
return (
<AppShell
title="Portfolio Matrix"
title="Portfolio"
subtitle="Position management, market valuation, and AI generated portfolio commentary."
actions={(
<>
@@ -209,7 +236,22 @@ export default function PortfolioPage() {
<Cell key={`${entry.name}-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: number | string | undefined) => formatCurrency(value)} />
<Tooltip
formatter={(value: number | string | undefined) => formatCurrency(value)}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
/>
<Legend
verticalAlign="bottom"
iconType="circle"
wrapperStyle={{ paddingTop: '0.75rem' }}
formatter={(value) => <span style={{ color: CHART_TEXT }}>{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
@@ -225,10 +267,33 @@ export default function PortfolioPage() {
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={performanceData}>
<CartesianGrid strokeDasharray="2 2" stroke="rgba(126, 217, 255, 0.2)" />
<XAxis dataKey="name" stroke="#8cb6c5" fontSize={12} />
<YAxis stroke="#8cb6c5" fontSize={12} />
<Tooltip formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`} />
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="name"
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
/>
<Tooltip
formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }}
/>
<Bar dataKey="value" fill="#68ffd5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
@@ -277,6 +342,8 @@ export default function PortfolioPage() {
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');