diff --git a/app/frontend/locales/en.ts b/app/frontend/locales/en.ts index a49031a..52602b1 100644 --- a/app/frontend/locales/en.ts +++ b/app/frontend/locales/en.ts @@ -139,6 +139,8 @@ const en = { title: 'Dividend income', thisMonth: 'This month', last12m: 'Last 12 months', + moreCurrencies_one: '+ {{count}} more currency', + moreCurrencies_other: '+ {{count}} more currencies', }, buyPlanTeaser: { title: 'Your next picks', diff --git a/app/frontend/locales/es.ts b/app/frontend/locales/es.ts index 93bc07d..5868ebd 100644 --- a/app/frontend/locales/es.ts +++ b/app/frontend/locales/es.ts @@ -139,6 +139,8 @@ const es = { title: 'Ingresos por dividendos', thisMonth: 'Este mes', last12m: 'Últimos 12 meses', + moreCurrencies_one: '+ {{count}} moneda más', + moreCurrencies_other: '+ {{count}} monedas más', }, buyPlanTeaser: { title: 'Tus próximas compras', diff --git a/app/frontend/pages/home/DashboardHome.tsx b/app/frontend/pages/home/DashboardHome.tsx index 0b4ad00..fc0e3fb 100644 --- a/app/frontend/pages/home/DashboardHome.tsx +++ b/app/frontend/pages/home/DashboardHome.tsx @@ -1,9 +1,11 @@ import { Send } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' +import { useMemo } from 'react' import { useLastAddedStocks, useMostAddedStocks, useMostHeldStocks } from '../../hooks/useStockQueries' import { useHoldings } from '../../hooks/useHoldingsQueries' import { useRadar } from '../../hooks/useRadarQueries' +import { useDividendChartData } from '../../hooks/useDividendsQueries' import { useTelegramLink } from '../../hooks/useTelegramLink' import { Card, CardContent } from '@/components/ui/card' import { PortfolioStatsCard } from '../../components/PortfolioStatsCard' @@ -20,6 +22,7 @@ export function DashboardHome() { const { data: holdingsData } = useHoldings() const { data: radarData } = useRadar() + const { data: chartData } = useDividendChartData() const { data: telegramLink } = useTelegramLink() const lastAddedQuery = useLastAddedStocks() const mostAddedQuery = useMostAddedStocks() @@ -28,9 +31,36 @@ export function DashboardHome() { const holdings = holdingsData?.holdings ?? [] const radarStocks = radarData?.stocks ?? [] const hasHoldings = holdings.length > 0 - const hasRadarStocks = radarStocks.length > 0 const telegramConnected = telegramLink?.connected ?? false + // Lift the empty-state checks so the layout can collapse to a single + // column when one half is null. Mirrors the in-window filter inside + // UpcomingExDividends, and counts any past-month income across all + // currencies for the mini. + const hasUpcomingExDivs = useMemo(() => { + const startOfToday = new Date() + startOfToday.setHours(0, 0, 0, 0) + const horizon = new Date(startOfToday) + horizon.setDate(horizon.getDate() + 14) + const inWindow = (d: string | null) => { + if (!d) return false + const x = new Date(d) + return !Number.isNaN(x.getTime()) && x >= startOfToday && x <= horizon + } + return ( + holdings.some((h) => inWindow(h.stock.exDividendDate)) || + radarStocks.some((r) => inWindow(r.exDividendDate)) + ) + }, [holdings, radarStocks]) + + const hasDividendIncome = useMemo(() => { + if (!chartData) return false + for (const buckets of Object.values(chartData.byCurrency)) { + if (buckets.some((b) => (b.actual ?? 0) > 0)) return true + } + return false + }, [chartData]) + return (
@@ -42,17 +72,23 @@ export function DashboardHome() { )} - {/* Today's actions: upcoming ex-divs (UpcomingExDividends returns - null when there's nothing in the next 14 days). */} - {(hasHoldings || hasRadarStocks) && ( - + {/* Upcoming ex-divs + dividend income — side-by-side when both + have data, single full-width column when only one does. The + grid class flips based on the lifted checks above so a missing + sibling doesn't leave a blank column. */} + {(hasUpcomingExDivs || hasDividendIncome) && ( +
+ {hasDividendIncome && } + {hasUpcomingExDivs && ( + + )} +
)} - {/* Dividend income mini (returns null when there's no recorded - dividend income). Single-column so a missing sibling doesn't - leave a gap on the right. */} - - {/* Buy plan teaser — hidden when the cart is empty. */} diff --git a/app/frontend/pages/home/auth/DividendIncomeMini.tsx b/app/frontend/pages/home/auth/DividendIncomeMini.tsx index 243b617..a2bba8e 100644 --- a/app/frontend/pages/home/auth/DividendIncomeMini.tsx +++ b/app/frontend/pages/home/auth/DividendIncomeMini.tsx @@ -2,91 +2,122 @@ import { useMemo } from 'react' import { Coins } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useDividendChartData } from '../../../hooks/useDividendsQueries' -import { useProfile } from '../../../hooks/useProfileQueries' import { Card, CardContent } from '@/components/ui/card' import { formatCurrency } from '../../../lib/currency' +import type { DividendChartBucket } from '../../../lib/api' -// Last-12-months sparkline of dividend income in the user's preferred -// currency. Shows this-month total prominently + a tiny bar chart for -// recent history. Returns null when there's no data so the parent can -// collapse the row gracefully. +interface CurrencyRow { + currency: string + monthly: DividendChartBucket[] + thisMonth: number + total: number +} + +const MAX_ROWS = 3 + +// Last-12-months dividend income on the dashboard. Renders one row per +// currency the user has actual income in (sorted by 12m total, capped +// at MAX_ROWS), so multi-currency portfolios don't lose data to a +// preferred-currency-only view. Each row shows the current-month total +// and a tiny 12-bar sparkline. Returns null when there's no actual +// income across any currency — empty $0 cards are just noise. export function DividendIncomeMini() { const { t } = useTranslation() const { data: chartData, isLoading } = useDividendChartData() - const { data: profile } = useProfile() - const currency = profile?.preferredCurrency ?? 'USD' + const rows = useMemo(() => { + if (!chartData) return [] + const result: CurrencyRow[] = [] - // The server returns 12 past + 12 future buckets per currency; we only - // want the *past* months for this mini (history, not projection). Past - // months are exactly the ones where `actual !== null` — that filter is - // more robust than slicing by index in case the server window changes. - const monthly = useMemo(() => { - if (!chartData) return null - const buckets = chartData.byCurrency[currency] ?? Object.values(chartData.byCurrency)[0] - if (!buckets) return null - const past = buckets.filter((b) => b.actual !== null) - // Keep the most recent 12 months — handles wider windows - // (e.g. range=full) gracefully. - return past.slice(-12) - }, [chartData, currency]) + for (const [currency, buckets] of Object.entries(chartData.byCurrency)) { + // Past-only entries (`actual` is null on future months) — robust to + // wider server windows like range=full. + const past = buckets.filter((b) => b.actual !== null).slice(-12) + if (past.length === 0) continue + const total = past.reduce((acc, b) => acc + (b.actual ?? 0), 0) + if (total === 0) continue + const thisMonth = past[past.length - 1]?.actual ?? 0 + result.push({ currency, monthly: past, thisMonth, total }) + } - if (isLoading) return null - if (!monthly || monthly.length === 0) return null + return result.sort((a, b) => b.total - a.total) + }, [chartData]) - const thisMonth = monthly[monthly.length - 1]?.actual ?? 0 - const max = Math.max(...monthly.map((m) => m.actual ?? 0), 1) + if (isLoading) return null + if (rows.length === 0) return null - // Hide entirely if the user has no recorded dividend income in the - // window — an empty $0 card with a flat sparkline is just noise for - // someone who hasn't logged dividends yet. - const total = monthly.reduce((acc, m) => acc + (m.actual ?? 0), 0) - if (total === 0) return null + const visible = rows.slice(0, MAX_ROWS) + const overflow = rows.length - visible.length return ( -
-
- - - -

- {t('home.dashboard.incomeMini.title')} -

-
+
+ + + +

+ {t('home.dashboard.incomeMini.title')} +

-
-

- {t('home.dashboard.incomeMini.thisMonth')} -

-

- {formatCurrency(thisMonth, currency)} -

-
- - {/* Sparkline-ish bar chart, pure CSS. */} -
- {monthly.map((m, i) => { - const value = m.actual ?? 0 - const heightPct = Math.max(2, (value / max) * 100) - const isCurrent = i === monthly.length - 1 +
    + {visible.map(({ currency, monthly, thisMonth, total }) => { + const max = Math.max(...monthly.map((m) => m.actual ?? 0), 1) return ( -
    +
  • +
    + + {currency} + +
    + + + {t('home.dashboard.incomeMini.thisMonth')} + + + {formatCurrency(thisMonth, currency)} + + + + + {t('home.dashboard.incomeMini.last12m')} + + + {formatCurrency(total, currency)} + + +
    +
    + + {/* Sparkline — current month highlighted. */} +
    + {monthly.map((m, i) => { + const value = m.actual ?? 0 + const heightPct = Math.max(2, (value / max) * 100) + const isCurrent = i === monthly.length - 1 + return ( +
    + ) + })} +
    +
  • ) })} -
    -

    - {t('home.dashboard.incomeMini.last12m')} -

    +
+ + {overflow > 0 && ( +

+ {t('home.dashboard.incomeMini.moreCurrencies', { count: overflow })} +

+ )} )