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 })}
+
+ )}
)