Phase 6: Expand market rates and enhance AI investment recommendations

- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates
  from Bankrate.com with pauses between fetches to avoid rate limiting
- Historical rate data is preserved (no longer deleted on each fetch)
- Database migration adds rate_type column and tenant ai_recommendations table
- Backend returns market rates grouped by type with latest-batch-only queries
- AI prompt now includes all three rate types for comprehensive analysis
- AI recommendations are saved per-tenant for retrieval on page load
- Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view
- Rates section is collapsible (expanded by default) to save screen space
- Saved recommendations load automatically with "Last Updated" timestamp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:39:19 -05:00
parent d9bb9363dd
commit 2fed5d6ce1
7 changed files with 686 additions and 317 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Title,
Text,
@@ -16,6 +16,9 @@ import {
Divider,
Accordion,
Paper,
Tabs,
Collapse,
ActionIcon,
} from '@mantine/core';
import {
IconBulb,
@@ -27,6 +30,8 @@ import {
IconRefresh,
IconCoin,
IconPigMoney,
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
@@ -59,15 +64,22 @@ interface FinancialSnapshot {
}>;
}
interface CdRate {
interface MarketRate {
bank_name: string;
apy: string;
min_deposit: string | null;
term: string;
term_months: number | null;
rate_type: string;
fetched_at: string;
}
interface MarketRatesResponse {
cd: MarketRate[];
money_market: MarketRate[];
high_yield_savings: MarketRate[];
}
interface Recommendation {
type: string;
priority: 'high' | 'medium' | 'low';
@@ -88,6 +100,15 @@ interface AIResponse {
risk_notes: string[];
}
interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
}
// ── Helpers ──
const fmt = (v: number) =>
@@ -117,10 +138,198 @@ const typeLabels: Record<string, string> = {
general: 'General',
};
// ── Component ──
// ── Rate Table Component ──
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
if (rates.length === 0) {
return (
<Text ta="center" c="dimmed" py="lg">
No rates available. Run the market rate fetcher to populate data.
</Text>
);
}
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
{showTerm && <Table.Th>Term</Table.Th>}
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
{showTerm && <Table.Td>{r.term}</Table.Td>}
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
// ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
return (
<Stack>
{/* Last Updated timestamp */}
{lastUpdated && (
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
)}
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
);
}
// ── Main Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
// Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -131,15 +340,36 @@ export function InvestmentPlanningPage() {
},
});
// Load CD rates on mount
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
queryKey: ['investment-planning-cd-rates'],
// Load market rates (all types) on mount
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
queryKey: ['investment-planning-market-rates'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/cd-rates');
const { data } = await api.get('/investment-planning/market-rates');
return data;
},
});
// Load saved recommendation on mount
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
});
// Populate AI results from saved recommendation on load
useEffect(() => {
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
@@ -148,6 +378,7 @@ export function InvestmentPlanningPage() {
},
onSuccess: (data) => {
setAiResult(data);
setLastUpdated(new Date().toISOString());
if (data.recommendations.length > 0) {
notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`,
@@ -173,6 +404,23 @@ export function InvestmentPlanningPage() {
const s = snapshot?.summary;
// Determine the latest fetched_at timestamp across all rate types
const allRatesList = [
...(marketRates?.cd || []),
...(marketRates?.money_market || []),
...(marketRates?.high_yield_savings || []),
];
const latestFetchedAt = allRatesList.length > 0
? allRatesList.reduce((latest, r) =>
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
).fetched_at
: null;
const totalRateCount =
(marketRates?.cd?.length || 0) +
(marketRates?.money_market?.length || 0) +
(marketRates?.high_yield_savings?.length || 0);
return (
<Stack>
{/* Page Header */}
@@ -312,57 +560,71 @@ export function InvestmentPlanningPage() {
</Card>
)}
{/* ── Section 3: Market CD Rates ── */}
{/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<Title order={4}>Market CD Rates</Title>
{cdRates.length > 0 && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()}
</Text>
)}
<Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
<Group gap="xs">
<Title order={4}>Today&apos;s Market Rates</Title>
{totalRateCount > 0 && (
<Badge size="sm" variant="light" color="gray">
{totalRateCount} rates
</Badge>
)}
</Group>
<Group gap="xs">
{latestFetchedAt && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
</Text>
)}
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setRatesExpanded((v) => !v)}
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
>
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</ActionIcon>
</Group>
</Group>
{ratesLoading ? (
<Center py="lg">
<Loader />
</Center>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
<Table.Th>Term</Table.Th>
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{cdRates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
<Table.Td>{r.term}</Table.Td>
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
{cdRates.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="lg">
No CD rates available. Run the fetch-cd-rates script to populate market data.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
<Collapse in={ratesExpanded}>
{ratesLoading ? (
<Center py="lg">
<Loader />
</Center>
) : (
<Tabs defaultValue="cd">
<Tabs.List>
<Tabs.Tab value="cd">
CDs {(marketRates?.cd?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="money_market">
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="high_yield_savings">
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="cd" pt="sm">
<RateTable rates={marketRates?.cd || []} showTerm={true} />
</Tabs.Panel>
<Tabs.Panel value="money_market" pt="sm">
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
</Tabs.Panel>
<Tabs.Panel value="high_yield_savings" pt="sm">
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
</Tabs.Panel>
</Tabs>
)}
</Collapse>
</Card>
<Divider />
@@ -409,137 +671,7 @@ export function InvestmentPlanningPage() {
{/* Results */}
{aiResult && !aiMutation.isPending && (
<Stack>
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
)}
{/* Empty State */}
@@ -552,7 +684,7 @@ export function InvestmentPlanningPage() {
AI-Powered Investment Analysis
</Text>
<Text c="dimmed" size="sm" maw={500} mx="auto">
Click "Get AI Recommendations" to analyze your accounts, cash flow,
Click &quot;Get AI Recommendations&quot; to analyze your accounts, cash flow,
budget, and capital projects against current market rates. The AI will
suggest specific investment moves to maximize interest income while
maintaining adequate liquidity.