From 1334d49bd2e4429af1767ad4340645eb0bffb0f1 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 09:35:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Publish=20radar=20to=20Pulse=20=E2=80=94=20?= =?UTF-8?q?single=20slug,=20two=20share=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of two coordinated PRs to add "share your radar on Pulse" alongside the existing portfolio sharing. This PR is the Quantic side: schema, NATS publishing, Settings UI. Pulse side (consumer + worker + LiveView + dashboard aggregates) comes next. Sharing model: one slug per user (the existing `portfolio_slug`) with two independent booleans `share_portfolio` (default true, back-compat) and `share_radar` (default false, opt-in). When both are set, the user gets `pulse.quantic.es/p/` AND `pulse.quantic.es/r/`. Publish flow mirrors the portfolio pipeline: - `User#publish_pulse_changes` (renamed from publish_portfolio_slug_change) detects all transitions across slug + the two toggles and emits the right opted_in / opted_out events per surface. Six cases covered in spec. - `RadarStock#after_commit :publish_radar_updated` re-emits `radar.updated` on any radar change (add / remove / target_price edit) — same pattern as `Holding#publish_portfolio_updated`. - New `RadarPayloadBuilder` builds a v1 payload with enough stock metadata (price, dividend_yield, 52w range, MA200) that Pulse can render a standalone radar page without needing the portfolio events. Settings UI: existing "Share on Pulse" card grows two checkbox rows inside the slug field. New `ShareToggle` component renders each toggle with a description and (when enabled) the live public URL. Pulse-side preview: Pulse already ignores unknown NATS subjects gracefully (Consumer.ex line 126), so deploying this Quantic PR first is safe — `radar.*` events fire but Pulse just logs warnings until PR 2 ships. Specs: 22 new examples covering the payload builder, all 6 publish transitions, RadarStock callback gating, and the controller's new fields. Full suite 676 examples / 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/api/v1/profiles_controller.rb | 10 ++- app/frontend/lib/api.ts | 4 + app/frontend/lib/pulse.ts | 8 ++ app/frontend/locales/en.ts | 9 +- app/frontend/locales/es.ts | 9 +- app/frontend/pages/SettingsPage.tsx | 89 +++++++++++++++---- app/frontend/types/index.ts | 2 + app/models/radar_stock.rb | 15 ++++ app/models/user.rb | 52 +++++++++-- app/services/radar_payload_builder.rb | 39 ++++++++ ...070000_add_pulse_share_toggles_to_users.rb | 9 ++ db/schema.rb | 4 +- spec/models/radar_stock_publishing_spec.rb | 28 ++++++ spec/models/user_pulse_publishing_spec.rb | 76 ++++++++++++++++ spec/requests/api/v1/profiles_spec.rb | 18 ++++ spec/services/radar_payload_builder_spec.rb | 39 ++++++++ 16 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 app/services/radar_payload_builder.rb create mode 100644 db/migrate/20260526070000_add_pulse_share_toggles_to_users.rb create mode 100644 spec/models/radar_stock_publishing_spec.rb create mode 100644 spec/models/user_pulse_publishing_spec.rb create mode 100644 spec/services/radar_payload_builder_spec.rb diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 9e6187d..9289073 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -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 diff --git a/app/frontend/lib/api.ts b/app/frontend/lib/api.ts index 3d4b80f..66d155e 100644 --- a/app/frontend/lib/api.ts +++ b/app/frontend/lib/api.ts @@ -443,6 +443,8 @@ export const dividendsApi = { export interface ProfileUpdate { portfolioSlug?: string | null preferredCurrency?: string + sharePortfolio?: boolean + shareRadar?: boolean } // ============================================================================ @@ -483,6 +485,8 @@ export const profileApi = { const body: Record = {} 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('/profile', { method: 'PATCH', body: JSON.stringify(body), diff --git a/app/frontend/lib/pulse.ts b/app/frontend/lib/pulse.ts index ca4d53e..2acef9e 100644 --- a/app/frontend/lib/pulse.ts +++ b/app/frontend/lib/pulse.ts @@ -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}` +} diff --git a/app/frontend/locales/en.ts b/app/frontend/locales/en.ts index 289e6f4..204e79a 100644 --- a/app/frontend/locales/en.ts +++ b/app/frontend/locales/en.ts @@ -381,11 +381,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: { diff --git a/app/frontend/locales/es.ts b/app/frontend/locales/es.ts index e9f322e..f79e249 100644 --- a/app/frontend/locales/es.ts +++ b/app/frontend/locales/es.ts @@ -380,10 +380,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.', diff --git a/app/frontend/pages/SettingsPage.tsx b/app/frontend/pages/SettingsPage.tsx index 15eb741..2ada9c1 100644 --- a/app/frontend/pages/SettingsPage.tsx +++ b/app/frontend/pages/SettingsPage.tsx @@ -11,7 +11,7 @@ import { Label } from '@/components/ui/label' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { CURRENCY_OPTIONS } from '@/lib/currency' -import { pulsePortfolioUrl, pulsePortfolioDisplayUrl } from '../lib/pulse' +import { pulsePortfolioUrl, pulsePortfolioDisplayUrl, pulseRadarUrl, pulseRadarDisplayUrl } from '../lib/pulse' export function SettingsPage() { const { t } = useTranslation() @@ -169,21 +169,33 @@ function PortfolioSharingSection() { )} - {slug && ( -

- {t('settings.publicUrl')}{' '} - - {pulsePortfolioDisplayUrl(slug)} - - -

- )} + + {/* What to share — only meaningful when a slug is set. Defaults: + portfolio ON (backwards-compat with the current behaviour), + radar OFF (opt-in for the new surface). */} + {profile?.portfolioSlug && ( +
+

{t('settings.sharing.whatToShare')}

+ updateProfile.mutate({ sharePortfolio: checked })} + pending={updateProfile.isPending} + publicUrl={profile.sharePortfolio ? { url: pulsePortfolioUrl(profile.portfolioSlug), label: pulsePortfolioDisplayUrl(profile.portfolioSlug) } : null} + /> + updateProfile.mutate({ shareRadar: checked })} + pending={updateProfile.isPending} + publicUrl={profile.shareRadar ? { url: pulseRadarUrl(profile.portfolioSlug), label: pulseRadarDisplayUrl(profile.portfolioSlug) } : null} + /> +
+ )} + {updateProfile.isError && ( @@ -201,6 +213,53 @@ function PortfolioSharingSection() { ) } +// Single labelled checkbox row used for both "Share portfolio" and +// "Share radar" inside the Pulse sharing card. Shows the resulting +// public URL when the toggle is on so the user sees exactly what they're +// exposing. +function ShareToggle({ + label, + description, + checked, + onChange, + pending, + publicUrl, +}: { + label: string + description: string + checked: boolean + onChange: (checked: boolean) => void + pending: boolean + publicUrl: { url: string; label: string } | null +}) { + return ( + + ) +} + function TelegramSection() { const { t } = useTranslation() const { data: status, isLoading, refetch } = useTelegramLink() diff --git a/app/frontend/types/index.ts b/app/frontend/types/index.ts index c72bd80..652730c 100644 --- a/app/frontend/types/index.ts +++ b/app/frontend/types/index.ts @@ -183,6 +183,8 @@ export interface UserProfile { emailAddress: string portfolioSlug: string | null preferredCurrency: string + sharePortfolio: boolean + shareRadar: boolean } /** diff --git a/app/models/radar_stock.rb b/app/models/radar_stock.rb index 776b56c..1b85787 100644 --- a/app/models/radar_stock.rb +++ b/app/models/radar_stock.rb @@ -11,4 +11,19 @@ class RadarStock < ApplicationRecord scope :with_target_price, -> { where.not(target_price: nil) } scope :without_target_price, -> { where(target_price: nil) } + + # Mirror Holding#publish_portfolio_updated: any change to a radar entry + # (add, remove, target_price tweak) re-publishes the full radar to Pulse + # so the public page stays in sync. Only fires when the user is actually + # sharing their radar. + after_commit :publish_radar_updated + + private + + def publish_radar_updated + user = radar&.user + return unless user&.portfolio_slug.present? && user.share_radar? + + NatsPublisher.publish("radar.updated", RadarPayloadBuilder.call(user)) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 630fc7a..4400800 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,8 @@ class User < ApplicationRecord if: -> { portfolio_slug.present? } validates :preferred_currency, presence: true, inclusion: { in: Stock::CURRENCY_SYMBOLS.keys } - after_commit :publish_portfolio_slug_change, if: :saved_change_to_portfolio_slug? + after_commit :publish_pulse_changes, + if: -> { saved_change_to_portfolio_slug? || saved_change_to_share_portfolio? || saved_change_to_share_radar? } # Find or create a user from OAuth provider data def self.from_omniauth(auth) @@ -30,12 +31,49 @@ def oauth_user? private - def publish_portfolio_slug_change - if portfolio_slug.present? - NatsPublisher.publish("portfolio.opted_in", PortfolioPayloadBuilder.call(self)) - else - previous_slug = saved_change_to_portfolio_slug.first - NatsPublisher.publish("portfolio.opted_out", { slug: previous_slug }) if previous_slug.present? + # On any save that touches portfolio_slug / share_portfolio / share_radar, + # diff the before/after state for each Pulse surface (portfolio + radar) + # and emit the right opted_in / opted_out events. `updated` events are + # owned by Holding / RadarStock callbacks — this method handles the + # opt-state lifecycle only. + def publish_pulse_changes + publish_surface_change(:portfolio, share_portfolio?) + publish_surface_change(:radar, share_radar?) + end + + # Emits `.opted_in` / `.opted_out` based on the + # *effective shared state* (slug present AND surface toggle on) before + # and after the save. Slug changes and toggle changes are both handled + # here, so a user clearing their slug fires opt-outs for any currently + # active surface. + def publish_surface_change(surface, currently_enabled_flag) + was_shared = previously_shared?(surface) + is_shared = portfolio_slug.present? && currently_enabled_flag + + if is_shared && !was_shared + NatsPublisher.publish("#{surface}.opted_in", payload_for(surface)) + elsif was_shared && !is_shared + slug_for_out = saved_change_to_portfolio_slug? ? saved_change_to_portfolio_slug.first : portfolio_slug + NatsPublisher.publish("#{surface}.opted_out", { slug: slug_for_out }) if slug_for_out.present? + end + end + + # Reconstruct the *previous* effective sharing state for `surface` by + # looking at saved_change_to_* on the slug and the relevant toggle. + # If a column wasn't part of this save its current value is also its + # "previous" value. + def previously_shared?(surface) + prev_slug = saved_change_to_portfolio_slug? ? saved_change_to_portfolio_slug.first : portfolio_slug + toggle_change = saved_change_to_share_portfolio if surface == :portfolio + toggle_change ||= saved_change_to_share_radar if surface == :radar + prev_flag = toggle_change ? toggle_change.first : public_send("share_#{surface}?") + prev_slug.present? && prev_flag + end + + def payload_for(surface) + case surface + when :portfolio then PortfolioPayloadBuilder.call(self) + when :radar then RadarPayloadBuilder.call(self) end end end diff --git a/app/services/radar_payload_builder.rb b/app/services/radar_payload_builder.rb new file mode 100644 index 0000000..af69aab --- /dev/null +++ b/app/services/radar_payload_builder.rb @@ -0,0 +1,39 @@ +module RadarPayloadBuilder + module_function + + # Pulse-side schema is fixed at v1 for now. We include enough stock + # metadata (price, dividend_yield, MA200, 52w range) that Pulse can + # render a standalone radar page even if the user isn't also sharing + # their portfolio — no NATS-based stock backfill in this first cut. + PAYLOAD_VERSION = 1 + + def call(user) + { + version: PAYLOAD_VERSION, + slug: user.portfolio_slug, + base_currency: user.preferred_currency || "USD", + stocks: serialize_stocks(user) + } + end + + def serialize_stocks(user) + radar = user.radar + return [] unless radar + + radar.radar_stocks.includes(:stock).map do |rs| + stock = rs.stock + { + symbol: stock.symbol, + name: stock.name, + currency: stock.currency, + sector: stock.sector, + price: stock.price&.to_f, + target_price: rs.target_price&.to_f, + dividend_yield: stock.dividend_yield&.to_f, + fifty_two_week_high: stock.fifty_two_week_high&.to_f, + fifty_two_week_low: stock.fifty_two_week_low&.to_f, + ma_200: stock.ma_200&.to_f + } + end + end +end diff --git a/db/migrate/20260526070000_add_pulse_share_toggles_to_users.rb b/db/migrate/20260526070000_add_pulse_share_toggles_to_users.rb new file mode 100644 index 0000000..2d72a95 --- /dev/null +++ b/db/migrate/20260526070000_add_pulse_share_toggles_to_users.rb @@ -0,0 +1,9 @@ +class AddPulseShareTogglesToUsers < ActiveRecord::Migration[8.0] + # `share_portfolio` defaults to true so anyone currently sharing keeps + # sharing seamlessly across the deploy. `share_radar` defaults to false + # — opt-in surface; users must explicitly enable it via Settings. + def change + add_column :users, :share_portfolio, :boolean, null: false, default: true + add_column :users, :share_radar, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index c3aa82b..65ec938 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_25_120001) do +ActiveRecord::Schema[8.0].define(version: 2026_05_26_070000) do create_table "ai_requests", force: :cascade do |t| t.integer "user_id", null: false t.string "feature", null: false @@ -169,6 +169,8 @@ t.boolean "admin", default: false, null: false t.string "portfolio_slug" t.string "preferred_currency", default: "USD", null: false + t.boolean "share_portfolio", default: true, null: false + t.boolean "share_radar", default: false, null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["portfolio_slug"], name: "index_users_on_portfolio_slug", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true diff --git a/spec/models/radar_stock_publishing_spec.rb b/spec/models/radar_stock_publishing_spec.rb new file mode 100644 index 0000000..8ad696f --- /dev/null +++ b/spec/models/radar_stock_publishing_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +RSpec.describe "RadarStock NATS publishing", type: :model do + let(:stock) { create(:stock, symbol: "AAPL") } + + before { allow(NatsPublisher).to receive(:publish) } + + it "publishes radar.updated when a radar stock changes for an opted-in user" do + user = create(:user, portfolio_slug: "alice", share_radar: true) + radar = create(:radar, user: user) + RadarStock.create!(radar: radar, stock: stock, target_price: 100) + expect(NatsPublisher).to have_received(:publish).with("radar.updated", anything) + end + + it "does not publish when the user has share_radar disabled" do + user = create(:user, portfolio_slug: "alice", share_radar: false) + radar = create(:radar, user: user) + RadarStock.create!(radar: radar, stock: stock, target_price: 100) + expect(NatsPublisher).not_to have_received(:publish).with("radar.updated", anything) + end + + it "does not publish when the user has no slug" do + user = create(:user, share_radar: true) + radar = create(:radar, user: user) + RadarStock.create!(radar: radar, stock: stock, target_price: 100) + expect(NatsPublisher).not_to have_received(:publish).with("radar.updated", anything) + end +end diff --git a/spec/models/user_pulse_publishing_spec.rb b/spec/models/user_pulse_publishing_spec.rb new file mode 100644 index 0000000..d70fb33 --- /dev/null +++ b/spec/models/user_pulse_publishing_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +# Targeted coverage for the User#publish_pulse_changes after-commit: +# the 6 transitions across (slug, share_portfolio, share_radar). +RSpec.describe "User Pulse publishing", type: :model do + before { allow(NatsPublisher).to receive(:publish) } + + describe "setting a slug for the first time" do + it "publishes portfolio.opted_in (default share_portfolio=true)" do + user = create(:user) + user.update!(portfolio_slug: "alice") + expect(NatsPublisher).to have_received(:publish).with("portfolio.opted_in", anything) + end + + it "also publishes radar.opted_in when share_radar is set on the same save" do + user = create(:user) + user.update!(portfolio_slug: "alice", share_radar: true) + expect(NatsPublisher).to have_received(:publish).with("portfolio.opted_in", anything) + expect(NatsPublisher).to have_received(:publish).with("radar.opted_in", anything) + end + + it "doesn't publish portfolio.opted_in when share_portfolio is false" do + user = create(:user, share_portfolio: false) + user.update!(portfolio_slug: "alice") + expect(NatsPublisher).not_to have_received(:publish).with("portfolio.opted_in", anything) + end + end + + describe "clearing the slug" do + it "publishes opted_out for whichever surfaces were active" do + user = create(:user, portfolio_slug: "alice", share_portfolio: true, share_radar: true) + user.update!(portfolio_slug: nil) + expect(NatsPublisher).to have_received(:publish).with("portfolio.opted_out", hash_including(slug: "alice")) + expect(NatsPublisher).to have_received(:publish).with("radar.opted_out", hash_including(slug: "alice")) + end + end + + describe "flipping share_radar on" do + it "publishes radar.opted_in (slug already present)" do + user = create(:user, portfolio_slug: "alice", share_radar: false) + user.update!(share_radar: true) + expect(NatsPublisher).to have_received(:publish).with("radar.opted_in", anything) + end + end + + describe "flipping share_radar off" do + it "publishes radar.opted_out (slug stays present)" do + user = create(:user, portfolio_slug: "alice", share_radar: true) + user.update!(share_radar: false) + expect(NatsPublisher).to have_received(:publish).with("radar.opted_out", hash_including(slug: "alice")) + end + end + + describe "flipping share_portfolio off (slug present)" do + it "publishes portfolio.opted_out without touching radar" do + user = create(:user, portfolio_slug: "alice", share_portfolio: true, share_radar: false) + user.update!(share_portfolio: false) + expect(NatsPublisher).to have_received(:publish).with("portfolio.opted_out", hash_including(slug: "alice")) + expect(NatsPublisher).not_to have_received(:publish).with("radar.opted_in", anything) + expect(NatsPublisher).not_to have_received(:publish).with("radar.opted_out", anything) + end + end + + describe "no relevant change" do + it "doesn't publish anything when changing preferred_currency only" do + user = create(:user, portfolio_slug: "alice") + # Reset the spy: the create(:user, portfolio_slug:) above fires the + # opt-in for the initial slug set; we only care about the next update. + RSpec::Mocks.space.proxy_for(NatsPublisher).reset + allow(NatsPublisher).to receive(:publish) + + user.update!(preferred_currency: "EUR") + expect(NatsPublisher).not_to have_received(:publish) + end + end +end diff --git a/spec/requests/api/v1/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb index 68614fe..a77caf5 100644 --- a/spec/requests/api/v1/profiles_spec.rb +++ b/spec/requests/api/v1/profiles_spec.rb @@ -66,6 +66,24 @@ expect(response).to have_http_status(:unprocessable_entity) end + + it "exposes and updates the sharePortfolio / shareRadar toggles" do + get "/api/v1/profile" + json = JSON.parse(response.body)["data"] + expect(json["sharePortfolio"]).to be(true) # default + expect(json["shareRadar"]).to be(false) # default + + patch "/api/v1/profile", params: { share_radar: true, share_portfolio: false } + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body)["data"] + expect(json["sharePortfolio"]).to be(false) + expect(json["shareRadar"]).to be(true) + end + + it "accepts string booleans (form-style clients)" do + patch "/api/v1/profile", params: { share_radar: "true" } + expect(JSON.parse(response.body)["data"]["shareRadar"]).to be(true) + end end end end diff --git a/spec/services/radar_payload_builder_spec.rb b/spec/services/radar_payload_builder_spec.rb new file mode 100644 index 0000000..59714f6 --- /dev/null +++ b/spec/services/radar_payload_builder_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +RSpec.describe RadarPayloadBuilder, type: :service do + let(:user) { create(:user, portfolio_slug: "alice", preferred_currency: "EUR") } + let(:radar) { create(:radar, user: user) } + + it "returns the v1 payload shape with empty stocks when the user has no radar" do + user_without_radar = create(:user, portfolio_slug: "bob") + payload = described_class.call(user_without_radar) + expect(payload).to include(version: 1, slug: "bob", stocks: []) + end + + it "serialises each radar stock with target_price and the metadata Pulse needs" do + stock = create(:stock, + symbol: "AAPL", name: "Apple Inc.", currency: "USD", sector: "Technology", + price: 178.50, dividend_yield: 0.56, + fifty_two_week_high: 199.62, fifty_two_week_low: 164.08, ma_200: 168.40) + RadarStock.create!(radar: radar, stock: stock, target_price: 160) + + payload = described_class.call(user) + + expect(payload[:version]).to eq(1) + expect(payload[:slug]).to eq("alice") + expect(payload[:base_currency]).to eq("EUR") + expect(payload[:stocks].length).to eq(1) + expect(payload[:stocks].first).to include( + symbol: "AAPL", + name: "Apple Inc.", + currency: "USD", + sector: "Technology", + price: 178.50, + target_price: 160.0, + dividend_yield: 0.56, + fifty_two_week_high: 199.62, + fifty_two_week_low: 164.08, + ma_200: 168.40 + ) + end +end From 0c492003e0b229ba37a058ea3ab708231599d60a Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 11:24:37 +0200 Subject: [PATCH 2/2] Radar polish: localize sectors, share button, fix insights title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small frontend fixes on the radar surface to address feedback during the share-radar-on-pulse round: 1. **Localize stock sectors**. New `translateSector(t, sector)` helper in `lib/sectors.ts` plus a `sectors` namespace in both locales covering the 17 GICS / Yahoo Finance names + `Unknown`. Unknown sectors pass through verbatim so a new provider sector never blanks the UI. Wired into `PortfolioStatsCard`'s sector legend and tooltip; matches the equivalent helper that just landed in Pulse for visual parity across both apps. 2. **Share-on-Pulse button on the radar page**. `PulseShareButton` is now parameterised with a `kind: 'portfolio' | 'radar'` prop — gates on the matching toggle (`sharePortfolio` / `shareRadar`) and links to the right URL. RadarPage gets the button under the title (gated on `radarStocks.length > 0`); PortfolioPage now passes `kind="portfolio"` explicitly. Also fixed the gate: if a slug exists but the relevant toggle is off, show the "opt-in from Settings" hint instead of a dead public link. 3. **Radar AI insights title**. `insights.title` was used by both `PortfolioInsights` and `RadarInsights` but said "AI Portfolio Insights" in both languages. Split into `insights.portfolioTitle` and `insights.radarTitle` so each component renders correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/frontend/components/PortfolioInsights.tsx | 2 +- .../components/PortfolioStatsCard.tsx | 6 ++-- app/frontend/components/PulseShareButton.tsx | 23 +++++++++++---- app/frontend/components/RadarInsights.tsx | 2 +- app/frontend/lib/sectors.ts | 6 ++++ app/frontend/locales/en.ts | 29 ++++++++++++++++++- app/frontend/locales/es.ts | 29 ++++++++++++++++++- app/frontend/pages/PortfolioPage.tsx | 2 +- app/frontend/pages/RadarPage.tsx | 14 +++++---- 9 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 app/frontend/lib/sectors.ts diff --git a/app/frontend/components/PortfolioInsights.tsx b/app/frontend/components/PortfolioInsights.tsx index 8e5e851..4726003 100644 --- a/app/frontend/components/PortfolioInsights.tsx +++ b/app/frontend/components/PortfolioInsights.tsx @@ -36,7 +36,7 @@ export function PortfolioInsights({ hasStocks }: PortfolioInsightsProps) {
- {t('insights.title')} + {t('insights.portfolioTitle')}
{isExpanded && ( diff --git a/app/frontend/components/PortfolioStatsCard.tsx b/app/frontend/components/PortfolioStatsCard.tsx index 57d3b9b..f6ba238 100644 --- a/app/frontend/components/PortfolioStatsCard.tsx +++ b/app/frontend/components/PortfolioStatsCard.tsx @@ -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' @@ -98,7 +100,7 @@ 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)}%`} /> ))}
@@ -106,7 +108,7 @@ function SectorBreakdown({ sectors }: { sectors: PortfolioStats['sectors'] }) { {sectors.map((s, i) => (
  • - {s.sector} + {translateSector(t, s.sector)} {s.percent.toFixed(1)}%
  • ))} diff --git a/app/frontend/components/PulseShareButton.tsx b/app/frontend/components/PulseShareButton.tsx index b046c5d..5585892 100644 --- a/app/frontend/components/PulseShareButton.tsx +++ b/app/frontend/components/PulseShareButton.tsx @@ -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 ( - {t('portfolio.shareOnPulseHint')} + {t(hintKey)} ) } return ( - {t('portfolio.shareOnPulse')} + {t(labelKey)} ) diff --git a/app/frontend/components/RadarInsights.tsx b/app/frontend/components/RadarInsights.tsx index bbb5ee0..035c655 100644 --- a/app/frontend/components/RadarInsights.tsx +++ b/app/frontend/components/RadarInsights.tsx @@ -36,7 +36,7 @@ export function RadarInsights({ hasStocks }: RadarInsightsProps) {
    - {t('insights.title')} + {t('insights.radarTitle')}
    {isExpanded && ( diff --git a/app/frontend/lib/sectors.ts b/app/frontend/lib/sectors.ts new file mode 100644 index 0000000..f1d4d47 --- /dev/null +++ b/app/frontend/lib/sectors.ts @@ -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 }) +} diff --git a/app/frontend/locales/en.ts b/app/frontend/locales/en.ts index 204e79a..d3863f3 100644 --- a/app/frontend/locales/en.ts +++ b/app/frontend/locales/en.ts @@ -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', @@ -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', @@ -559,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 diff --git a/app/frontend/locales/es.ts b/app/frontend/locales/es.ts index f79e249..e998f09 100644 --- a/app/frontend/locales/es.ts +++ b/app/frontend/locales/es.ts @@ -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', @@ -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', @@ -558,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 diff --git a/app/frontend/pages/PortfolioPage.tsx b/app/frontend/pages/PortfolioPage.tsx index c3c39b8..daec30b 100644 --- a/app/frontend/pages/PortfolioPage.tsx +++ b/app/frontend/pages/PortfolioPage.tsx @@ -166,7 +166,7 @@ export function PortfolioPage() { {t('portfolio.title')} - {holdings.length > 0 && } + {holdings.length > 0 && }
    {holdingsData && holdings.length > 0 && ( -
    - - - {t('radar.title')} - +
    +
    + + + {t('radar.title')} + + {radarStocks.length > 0 && } +