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
10 changes: 8 additions & 2 deletions app/controllers/api/v1/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ def serialize_profile
id: Current.user.id,
emailAddress: Current.user.email_address,
portfolioSlug: Current.user.portfolio_slug,
preferredCurrency: Current.user.preferred_currency
preferredCurrency: Current.user.preferred_currency,
sharePortfolio: Current.user.share_portfolio,
shareRadar: Current.user.share_radar
}
end

def profile_params
permitted = params.permit(:portfolio_slug, :preferred_currency)
permitted = params.permit(:portfolio_slug, :preferred_currency, :share_portfolio, :share_radar)
permitted[:portfolio_slug] = nil if permitted.key?(:portfolio_slug) && permitted[:portfolio_slug].blank?
# Coerce form-y string booleans to real booleans so AR doesn't choke.
%i[share_portfolio share_radar].each do |k|
permitted[k] = ActiveModel::Type::Boolean.new.cast(permitted[k]) if permitted.key?(k)
end
permitted
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/components/PortfolioInsights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function PortfolioInsights({ hasStocks }: PortfolioInsightsProps) {
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Sparkles className="size-5 text-violet-500" />
{t('insights.title')}
{t('insights.portfolioTitle')}
</CardTitle>
<div className="flex items-center gap-2">
{isExpanded && (
Expand Down
6 changes: 4 additions & 2 deletions app/frontend/components/PortfolioStatsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useTranslation } from 'react-i18next'

import { translateSector } from '@/lib/sectors'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import type { PortfolioStats } from '../types'
Expand Down Expand Up @@ -98,15 +100,15 @@ function SectorBreakdown({ sectors }: { sectors: PortfolioStats['sectors'] }) {
key={s.sector}
className={cn('h-full', SECTOR_COLORS[i % SECTOR_COLORS.length])}
style={{ width: `${s.percent}%` }}
title={`${s.sector}: ${s.percent.toFixed(1)}%`}
title={`${translateSector(t, s.sector)}: ${s.percent.toFixed(1)}%`}
/>
))}
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{sectors.map((s, i) => (
<li key={s.sector} className="flex items-center gap-2">
<span className={cn('size-2 rounded-sm', SECTOR_COLORS[i % SECTOR_COLORS.length])} />
<span className="text-foreground">{s.sector}</span>
<span className="text-foreground">{translateSector(t, s.sector)}</span>
<span className="text-muted-foreground">{s.percent.toFixed(1)}%</span>
</li>
))}
Expand Down
23 changes: 17 additions & 6 deletions app/frontend/components/PulseShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,47 @@ import { ExternalLink, Activity } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useProfile } from '../hooks/useProfileQueries'
import { pulsePortfolioUrl } from '../lib/pulse'
import { pulsePortfolioUrl, pulseRadarUrl } from '../lib/pulse'

export function PulseShareButton() {
type Kind = 'portfolio' | 'radar'

interface Props {
kind?: Kind
}

export function PulseShareButton({ kind = 'portfolio' }: Props) {
const { t } = useTranslation()
const { data: profile, isLoading } = useProfile()

if (isLoading) return null

const slug = profile?.portfolioSlug
if (!slug) {
const enabled = kind === 'portfolio' ? profile?.sharePortfolio : profile?.shareRadar
const url = kind === 'portfolio' ? pulsePortfolioUrl : pulseRadarUrl
const labelKey = kind === 'portfolio' ? 'portfolio.shareOnPulse' : 'radar.shareOnPulse'
const hintKey = kind === 'portfolio' ? 'portfolio.shareOnPulseHint' : 'radar.shareOnPulseHint'

if (!slug || !enabled) {
return (
<Link
to="/settings#portfolio-sharing"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-purple-600 dark:hover:text-purple-400 hover:underline"
>
<Activity className="size-4 text-purple-600 dark:text-purple-400" />
{t('portfolio.shareOnPulseHint')}
{t(hintKey)}
</Link>
)
}

return (
<a
href={pulsePortfolioUrl(slug)}
href={url(slug)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-purple-700 dark:text-purple-300 hover:underline"
>
<Activity className="size-4" />
{t('portfolio.shareOnPulse')}
{t(labelKey)}
<ExternalLink className="size-3" />
</a>
)
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/components/RadarInsights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function RadarInsights({ hasStocks }: RadarInsightsProps) {
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Sparkles className="size-5 text-violet-500" />
{t('insights.title')}
{t('insights.radarTitle')}
</CardTitle>
<div className="flex items-center gap-2">
{isExpanded && (
Expand Down
4 changes: 4 additions & 0 deletions app/frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ export const dividendsApi = {
export interface ProfileUpdate {
portfolioSlug?: string | null
preferredCurrency?: string
sharePortfolio?: boolean
shareRadar?: boolean
}

// ============================================================================
Expand Down Expand Up @@ -483,6 +485,8 @@ export const profileApi = {
const body: Record<string, unknown> = {}
if ('portfolioSlug' in update) body.portfolio_slug = update.portfolioSlug
if ('preferredCurrency' in update) body.preferred_currency = update.preferredCurrency
if ('sharePortfolio' in update) body.share_portfolio = update.sharePortfolio
if ('shareRadar' in update) body.share_radar = update.shareRadar
return apiFetch<UserProfile>('/profile', {
method: 'PATCH',
body: JSON.stringify(body),
Expand Down
8 changes: 8 additions & 0 deletions app/frontend/lib/pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ export function pulsePortfolioUrl(slug: string): string {
export function pulsePortfolioDisplayUrl(slug: string): string {
return `${PULSE_URL.replace(/^https?:\/\//, '')}/p/${slug}`
}

export function pulseRadarUrl(slug: string): string {
return `${PULSE_URL}/r/${slug}`
}

export function pulseRadarDisplayUrl(slug: string): string {
return `${PULSE_URL.replace(/^https?:\/\//, '')}/r/${slug}`
}
6 changes: 6 additions & 0 deletions app/frontend/lib/sectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { TFunction } from 'i18next'

export function translateSector(t: TFunction, sector: string | null | undefined): string {
if (!sector) return ''
return t(`sectors.${sector}`, { defaultValue: sector })
}
38 changes: 36 additions & 2 deletions app/frontend/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ const en = {
failedToLoadRadar: 'Failed to load radar',
failedToRemoveStock: 'Failed to remove stock',
dividendCalendar: 'Dividend Calendar',
shareOnPulse: 'Share on Pulse',
shareOnPulseHint: 'Share your radar on Pulse — opt in from Settings',
switchToCardView: 'Switch to card view',
switchToCompactView: 'Switch to compact view',
addToCart: 'Add to Cart',
Expand Down Expand Up @@ -335,7 +337,8 @@ const en = {

// AI Insights (shared between radar and portfolio)
insights: {
title: 'AI Portfolio Insights',
portfolioTitle: 'AI Portfolio Insights',
radarTitle: 'AI Radar Insights',
buyingOpportunities: 'Buying Opportunities',
coverageGaps: 'Dividend Coverage Gaps',
riskFlags: 'Risk Flags',
Expand Down Expand Up @@ -381,11 +384,18 @@ const en = {
settings: {
title: 'Settings',
portfolioSharing: 'Share on Pulse',
sharingDescription: 'Pulse is the Quantic community for sharing portfolios. Pick a public name to opt in — leave it empty to stay private.',
sharingDescription: 'Pulse is the Quantic community for sharing portfolios and radars. Pick a public name to opt in — leave it empty to stay private.',
portfolioSlug: 'Public name',
slugPlaceholder: 'my-portfolio',
publicUrl: 'Public URL:',
failedToUpdate: 'Failed to update',
sharing: {
whatToShare: 'What to share publicly',
portfolio: 'Portfolio',
portfolioDescription: 'Your holdings, allocations, and total value (in your display currency).',
radar: 'Radar',
radarDescription: 'Your watchlist with target prices. No quantities or holdings — just what you’re tracking and at what price.',
},
displayCurrency: 'Display Currency',
displayCurrencyDescription: 'Choose the currency used to sum multi-currency totals. Per-stock prices stay in their listing currency.',
telegram: {
Expand Down Expand Up @@ -552,6 +562,30 @@ const en = {
body: "You've used your {{limit}} free AI requests today. AI calls cost real money \u2014 we're offering them free for now. Try again tomorrow.",
},
},

// GICS / Yahoo Finance stock sectors. Looked up by raw provider name
// (see translateSector in lib/sectors.ts) \u2014 unknown names pass through
// verbatim so a new sector never blanks the UI.
sectors: {
'Basic Materials': 'Basic Materials',
'Communication Services': 'Communication Services',
'Consumer Cyclical': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Defensive',
'Consumer Discretionary': 'Consumer Discretionary',
'Consumer Staples': 'Consumer Staples',
Energy: 'Energy',
'Financial Services': 'Financial Services',
Financials: 'Financials',
'Health Care': 'Health Care',
Healthcare: 'Healthcare',
Industrials: 'Industrials',
'Information Technology': 'Information Technology',
Materials: 'Materials',
'Real Estate': 'Real Estate',
Technology: 'Technology',
Utilities: 'Utilities',
Unknown: 'Unknown',
},
} as const

export default en
38 changes: 36 additions & 2 deletions app/frontend/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ const es = {
failedToLoadRadar: 'Error al cargar el radar',
failedToRemoveStock: 'Error al eliminar acci\u00f3n',
dividendCalendar: 'Calendario de Dividendos',
shareOnPulse: 'Compartir en Pulse',
shareOnPulseHint: 'Comparte tu radar en Pulse \u2014 act\u00edvalo en Ajustes',
switchToCardView: 'Cambiar a vista de tarjetas',
switchToCompactView: 'Cambiar a vista compacta',
addToCart: 'A\u00f1adir al Carrito',
Expand Down Expand Up @@ -334,7 +336,8 @@ const es = {

// AI Insights
insights: {
title: 'An\u00e1lisis IA de Cartera',
portfolioTitle: 'An\u00e1lisis IA de Cartera',
radarTitle: 'An\u00e1lisis IA del Radar',
buyingOpportunities: 'Oportunidades de Compra',
coverageGaps: 'Meses sin Cobertura de Dividendos',
riskFlags: 'Alertas de Riesgo',
Expand Down Expand Up @@ -380,10 +383,17 @@ const es = {
settings: {
title: 'Ajustes',
portfolioSharing: 'Compartir en Pulse',
sharingDescription: 'Pulse es la comunidad de Quantic para compartir carteras. Elige un nombre p\u00fablico para participar \u2014 d\u00e9jalo vac\u00edo para no participar.',
sharingDescription: 'Pulse es la comunidad de Quantic para compartir carteras y radares. Elige un nombre p\u00fablico para participar \u2014 d\u00e9jalo vac\u00edo para no participar.',
portfolioSlug: 'Nombre p\u00fablico',
slugPlaceholder: 'mi-cartera',
publicUrl: 'URL p\u00fablica:',
sharing: {
whatToShare: 'Qu\u00e9 compartir p\u00fablicamente',
portfolio: 'Cartera',
portfolioDescription: 'Tus posiciones, asignaciones y valor total (en tu moneda de visualizaci\u00f3n).',
radar: 'Radar',
radarDescription: 'Tu lista de seguimiento con precios objetivo. Sin cantidades ni posiciones \u2014 solo qu\u00e9 est\u00e1s siguiendo y a qu\u00e9 precio.',
},
failedToUpdate: 'Error al actualizar',
displayCurrency: 'Moneda de visualizaci\u00f3n',
displayCurrencyDescription: 'Elige la moneda que se usa para sumar totales multi-divisa. Los precios por acci\u00f3n se mantienen en su moneda de cotizaci\u00f3n.',
Expand Down Expand Up @@ -551,6 +561,30 @@ const es = {
body: 'Has usado tus {{limit}} solicitudes gratuitas de IA hoy. Las llamadas a la IA cuestan dinero real — las ofrecemos gratis por ahora. Inténtalo de nuevo mañana.',
},
},

// GICS / Yahoo Finance stock sectors. Looked up by raw provider name
// (see translateSector in lib/sectors.ts) — unknown names pass through
// verbatim so a new sector never blanks the UI.
sectors: {
'Basic Materials': 'Materiales Básicos',
'Communication Services': 'Servicios de Comunicación',
'Consumer Cyclical': 'Consumo Cíclico',
'Consumer Defensive': 'Consumo Defensivo',
'Consumer Discretionary': 'Consumo Discrecional',
'Consumer Staples': 'Consumo Básico',
Energy: 'Energía',
'Financial Services': 'Servicios Financieros',
Financials: 'Finanzas',
'Health Care': 'Salud',
Healthcare: 'Salud',
Industrials: 'Industria',
'Information Technology': 'Tecnología de la Información',
Materials: 'Materiales',
'Real Estate': 'Inmobiliario',
Technology: 'Tecnología',
Utilities: 'Servicios Públicos',
Unknown: 'Desconocido',
},
} as const

export default es
2 changes: 1 addition & 1 deletion app/frontend/pages/PortfolioPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function PortfolioPage() {
<span className="w-1 h-8 bg-foreground rounded-full"></span>
{t('portfolio.title')}
</CardTitle>
{holdings.length > 0 && <PulseShareButton />}
{holdings.length > 0 && <PulseShareButton kind="portfolio" />}
</div>
{holdingsData && holdings.length > 0 && (
<PortfolioTotalsHeader
Expand Down
14 changes: 9 additions & 5 deletions app/frontend/pages/RadarPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CartSummaryBar } from '../components/CartSummaryBar'
import { CartDrawer } from '../components/CartDrawer'
import { DividendCalendar } from '../components/DividendCalendar'
import { RadarInsights } from '../components/RadarInsights'
import { PulseShareButton } from '../components/PulseShareButton'
import { useRadar, useAddStock, useRemoveStock } from '../hooks/useRadarQueries'
import { useStockSearch, useResolveStock } from '../hooks/useStockQueries'
import { useViewPreference } from '../contexts/ViewPreferenceContext'
Expand Down Expand Up @@ -97,11 +98,14 @@ export function RadarPage() {
<div className="container mx-auto px-4 py-8">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-2xl sm:text-3xl flex items-center gap-2">
<span className="w-1 h-8 bg-foreground rounded-full"></span>
{t('radar.title')}
</CardTitle>
<div className="flex items-start justify-between gap-2">
<div className="space-y-2 min-w-0">
<CardTitle className="text-2xl sm:text-3xl flex items-center gap-2">
<span className="w-1 h-8 bg-foreground rounded-full"></span>
{t('radar.title')}
</CardTitle>
{radarStocks.length > 0 && <PulseShareButton kind="radar" />}
</div>
<BuyPlanModeToggle />
</div>
</CardHeader>
Expand Down
Loading
Loading