Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/frontend/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions app/frontend/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
56 changes: 46 additions & 10 deletions app/frontend/pages/home/DashboardHome.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand All @@ -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 (
<div className="container mx-auto px-4 py-8 space-y-8 md:space-y-10">
<DashboardGreeting />
Expand All @@ -42,17 +72,23 @@ export function DashboardHome() {
<EmptyPortfolioCTA />
)}

{/* Today's actions: upcoming ex-divs (UpcomingExDividends returns
null when there's nothing in the next 14 days). */}
{(hasHoldings || hasRadarStocks) && (
<UpcomingExDividends holdings={holdings} radarStocks={radarStocks} />
{/* 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) && (
<div
className={`grid gap-4 ${
hasUpcomingExDivs && hasDividendIncome ? 'lg:grid-cols-2' : ''
}`}
>
{hasDividendIncome && <DividendIncomeMini />}
{hasUpcomingExDivs && (
<UpcomingExDividends holdings={holdings} radarStocks={radarStocks} />
)}
</div>
)}

{/* 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. */}
<DividendIncomeMini />

{/* Buy plan teaser — hidden when the cart is empty. */}
<BuyPlanTeaser />

Expand Down
161 changes: 96 additions & 65 deletions app/frontend/pages/home/auth/DividendIncomeMini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CurrencyRow[]>(() => {
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 (
<Card>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<span className="inline-flex size-8 items-center justify-center rounded-lg bg-gradient-emerald-cyan text-white">
<Coins className="size-4" />
</span>
<h3 className="text-sm font-semibold">
{t('home.dashboard.incomeMini.title')}
</h3>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex size-8 items-center justify-center rounded-lg bg-gradient-emerald-cyan text-white">
<Coins className="size-4" />
</span>
<h3 className="text-sm font-semibold">
{t('home.dashboard.incomeMini.title')}
</h3>
</div>

<div className="mb-3">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
{t('home.dashboard.incomeMini.thisMonth')}
</p>
<p className="text-2xl font-bold tabular-nums">
{formatCurrency(thisMonth, currency)}
</p>
</div>

{/* Sparkline-ish bar chart, pure CSS. */}
<div className="flex items-end gap-1 h-12">
{monthly.map((m, i) => {
const value = m.actual ?? 0
const heightPct = Math.max(2, (value / max) * 100)
const isCurrent = i === monthly.length - 1
<ul className="space-y-4">
{visible.map(({ currency, monthly, thisMonth, total }) => {
const max = Math.max(...monthly.map((m) => m.actual ?? 0), 1)
return (
<div
key={m.month}
title={`${m.month}: ${formatCurrency(value, currency)}`}
className={`flex-1 rounded-sm transition-colors ${
isCurrent ? 'bg-emerald-500' : 'bg-emerald-500/30'
}`}
style={{ height: `${heightPct}%` }}
/>
<li key={currency} className="space-y-1.5">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
{currency}
</span>
<div className="flex items-baseline gap-4">
<span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide mr-1">
{t('home.dashboard.incomeMini.thisMonth')}
</span>
<span className="text-sm font-bold tabular-nums">
{formatCurrency(thisMonth, currency)}
</span>
</span>
<span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide mr-1">
{t('home.dashboard.incomeMini.last12m')}
</span>
<span className="text-sm font-bold tabular-nums">
{formatCurrency(total, currency)}
</span>
</span>
</div>
</div>

{/* Sparkline — current month highlighted. */}
<div className="flex items-end gap-0.5 h-7">
{monthly.map((m, i) => {
const value = m.actual ?? 0
const heightPct = Math.max(2, (value / max) * 100)
const isCurrent = i === monthly.length - 1
return (
<div
key={m.month}
title={`${m.month}: ${formatCurrency(value, currency)}`}
className={`flex-1 rounded-sm ${
isCurrent ? 'bg-emerald-500' : 'bg-emerald-500/30'
}`}
style={{ height: `${heightPct}%` }}
/>
)
})}
</div>
</li>
)
})}
</div>
<p className="mt-2 text-[10px] text-muted-foreground uppercase tracking-wide">
{t('home.dashboard.incomeMini.last12m')}
</p>
</ul>

{overflow > 0 && (
<p className="mt-3 text-[10px] text-muted-foreground uppercase tracking-wide">
{t('home.dashboard.incomeMini.moreCurrencies', { count: overflow })}
</p>
)}
</CardContent>
</Card>
)
Expand Down
Loading