From 51307d0fd991097b58ee8dbc2ca182aa43e40cfb Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 09:45:54 +0200 Subject: [PATCH 1/8] Consume radar.* events: RadarWorker + LiveView + community insights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of two coordinated PRs. The Quantic side (dividend-portfolio #171) ships the publishing flow + Settings UI. This is the Pulse mirror: a new GenServer-per-slug RadarWorker tree, a /r/:slug LiveView, and a "Community watchlist" card on the dashboard. ## OTP architecture (mirrors the portfolio tree exactly) - `Pulse.RadarRegistry` — Registry (keys: :unique) for slug → pid lookup, separate namespace from PortfolioRegistry. - `Pulse.RadarSupervisor` — DynamicSupervisor managing RadarWorker children. - `Pulse.RadarWorker` — GenServer per shared slug. State is stocks + base_currency + computed metrics (stock_count, targeted_count, below_target_count). Recomputes on each `radar.updated`; broadcasts on `radar:#{slug}` and `radars` PubSub. - `Pulse.RadarStore` — DETS-backed persistence (priv/data/radars.dets, separate from portfolios.dets), with `restore_all` to rehydrate workers on app boot. - `Pulse.RadarAggregator` — community stats GenServer mirroring DashboardAggregator: top-N most-watched stocks, average target prices (suppressed below MIN_COHORT = 3 to avoid exposing a single user's preference), and "below community target" derived view sorted by largest gap. ## Consumer + Application - `Pulse.Nats.Consumer.@subjects` extends with `radar.{updated, opted_in,opted_out}`. Each handler mirrors the corresponding portfolio handler: opted_in starts the worker (and fills stocks if present), opted_out tears it down and clears DETS, updated ensures a worker exists then forwards. - `Pulse.Application` adds the radar registry, supervisor, store, aggregator, and a second restore Task. The two restore tasks use `Supervisor.child_spec` with explicit IDs to avoid the default-id collision. ## Frontend - New `PulseWeb.RadarLive` at `/r/:slug`. Subscribes to `radar:#{slug}` PubSub so target-price edits push without a refresh. Renders a stocks table with Symbol / Sector / Price / Target / Delta (color-coded) / Yield. Cross-links to `/p/:slug` when the same slug also has a PortfolioWorker. - `PulseWeb.DashboardLive` gets a new "Community watchlist" card with the most-watched table + a "below community target" sub-section. Subscribes to the existing `dashboard` topic; the RadarAggregator broadcasts `{:radar_dashboard_updated, stats}` there. - Router: `live "/r/:slug", RadarLive, :show`. ## Tests 44 total, 0 failures (7 new across RadarWorker + RadarAggregator covering the worker lifecycle, metric computation, material-change gating on updated_at, PubSub broadcast, and the aggregator's MIN_COHORT threshold + below-target derivation). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/pulse/application.ex | 14 ++- lib/pulse/nats/consumer.ex | 44 +++++++- lib/pulse/radar_aggregator.ex | 131 ++++++++++++++++++++++ lib/pulse/radar_store.ex | 145 +++++++++++++++++++++++++ lib/pulse/radar_supervisor.ex | 36 +++++++ lib/pulse/radar_worker.ex | 135 +++++++++++++++++++++++ lib/pulse_web/live/dashboard_live.ex | 89 ++++++++++++++- lib/pulse_web/live/radar_live.ex | 156 +++++++++++++++++++++++++++ lib/pulse_web/router.ex | 1 + test/pulse/radar_aggregator_test.exs | 58 ++++++++++ test/pulse/radar_worker_test.exs | 81 ++++++++++++++ 11 files changed, 885 insertions(+), 5 deletions(-) create mode 100644 lib/pulse/radar_aggregator.ex create mode 100644 lib/pulse/radar_store.ex create mode 100644 lib/pulse/radar_supervisor.ex create mode 100644 lib/pulse/radar_worker.ex create mode 100644 lib/pulse_web/live/radar_live.ex create mode 100644 test/pulse/radar_aggregator_test.exs create mode 100644 test/pulse/radar_worker_test.exs diff --git a/lib/pulse/application.ex b/lib/pulse/application.ex index 787d23d..749acde 100644 --- a/lib/pulse/application.ex +++ b/lib/pulse/application.ex @@ -16,11 +16,19 @@ defmodule Pulse.Application do {Pulse.Store, []}, {Pulse.Analytics, []}, - # Dashboard aggregator (must start before NATS consumer) + # Radar OTP tree (mirror of the portfolio tree) + {Registry, keys: :unique, name: Pulse.RadarRegistry}, + {Pulse.RadarSupervisor, []}, + {Pulse.RadarStore, []}, + + # Aggregators (must start before NATS consumer so they're subscribed) Pulse.DashboardAggregator, + Pulse.RadarAggregator, - # Restore persisted portfolios before NATS events arrive - {Task, fn -> Pulse.Store.restore_all() end}, + # Restore persisted state before NATS events arrive. Two tasks need + # unique child IDs (the default id is just `Task`, which would collide). + Supervisor.child_spec({Task, fn -> Pulse.Store.restore_all() end}, id: :portfolio_restore), + Supervisor.child_spec({Task, fn -> Pulse.RadarStore.restore_all() end}, id: :radar_restore), # NATS connection and consumer Pulse.Nats.Connection, diff --git a/lib/pulse/nats/consumer.ex b/lib/pulse/nats/consumer.ex index 4c393fe..4a52f3c 100644 --- a/lib/pulse/nats/consumer.ex +++ b/lib/pulse/nats/consumer.ex @@ -9,7 +9,11 @@ defmodule Pulse.Nats.Consumer do require Logger - @subjects ~w(portfolio.updated portfolio.opted_in portfolio.opted_out stock.price_updated) + @subjects ~w( + portfolio.updated portfolio.opted_in portfolio.opted_out + radar.updated radar.opted_in radar.opted_out + stock.price_updated + ) def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) @@ -123,6 +127,44 @@ defmodule Pulse.Nats.Consumer do Phoenix.PubSub.broadcast(Pulse.PubSub, "stocks", {:price_updated, payload}) end + # ── Radar events (mirror of the portfolio path) ────────────────────── + + defp handle_event("radar.opted_in", %{"slug" => slug} = payload) do + Logger.info("Radar opted in: #{slug}") + + case Pulse.RadarSupervisor.start_worker(slug) do + {:ok, _pid} -> + if payload["stocks"], do: Pulse.RadarWorker.update_stocks(slug, payload) + + {:error, {:already_started, _pid}} -> + Logger.info("Radar worker already exists for #{slug}, updating stocks") + if payload["stocks"], do: Pulse.RadarWorker.update_stocks(slug, payload) + + {:error, reason} -> + Logger.error("Failed to start radar worker for #{slug}: #{inspect(reason)}") + end + end + + defp handle_event("radar.opted_out", %{"slug" => slug}) do + Logger.info("Radar opted out: #{slug}") + Pulse.RadarStore.delete(slug) + Pulse.RadarSupervisor.stop_worker(slug) + end + + defp handle_event("radar.updated", %{"slug" => slug, "stocks" => _} = payload) do + Logger.info("Radar updated: #{slug}") + + case Registry.lookup(Pulse.RadarRegistry, slug) do + [{_pid, _}] -> + Pulse.RadarWorker.update_stocks(slug, payload) + + [] -> + Logger.warning("No radar worker for #{slug}, starting one") + Pulse.RadarSupervisor.start_worker(slug) + Pulse.RadarWorker.update_stocks(slug, payload) + end + end + defp handle_event(topic, _payload) do Logger.warning("Unhandled NATS event: #{topic}") end diff --git a/lib/pulse/radar_aggregator.ex b/lib/pulse/radar_aggregator.ex new file mode 100644 index 0000000..8998f92 --- /dev/null +++ b/lib/pulse/radar_aggregator.ex @@ -0,0 +1,131 @@ +defmodule Pulse.RadarAggregator do + @moduledoc """ + GenServer that aggregates community-wide radar stats across all active + `Pulse.RadarWorker` processes — mirror of `Pulse.DashboardAggregator`. + + Powers the "Community watchlist" card on the Pulse dashboard: + - `most_watched` — top symbols by count of radars they appear in. + Includes average target price when the cohort has at least + `@min_cohort` users (avoids exposing a single user's preference). + - `below_community_target` — same list filtered to current_price < + avg_target, sorted by largest gap (consensus buy signals). + """ + use GenServer + + require Logger + + @recompute_debounce 500 + @min_cohort 3 + @top_n 10 + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def get_stats do + GenServer.call(__MODULE__, :get_stats) + end + + def refresh do + GenServer.call(__MODULE__, :refresh) + end + + @impl true + def init(_opts) do + Phoenix.PubSub.subscribe(Pulse.PubSub, "radars") + {:ok, %{stats: compute_stats(), debounce_ref: nil}} + end + + @impl true + def handle_call(:get_stats, _from, state) do + {:reply, state.stats, state} + end + + def handle_call(:refresh, _from, state) do + stats = compute_stats() + {:reply, stats, %{state | stats: stats}} + end + + @impl true + def handle_info(:recompute, state) do + stats = compute_stats() + Phoenix.PubSub.broadcast(Pulse.PubSub, "dashboard", {:radar_dashboard_updated, stats}) + {:noreply, %{state | stats: stats, debounce_ref: nil}} + end + + def handle_info({:radar_changed, _slug}, state) do + if state.debounce_ref, do: Process.cancel_timer(state.debounce_ref) + ref = Process.send_after(self(), :recompute, @recompute_debounce) + {:noreply, %{state | debounce_ref: ref}} + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + defp compute_stats do + children = DynamicSupervisor.which_children(Pulse.RadarSupervisor) + + radars = + children + |> Enum.filter(fn {_, pid, _, _} -> is_pid(pid) end) + |> Enum.map(fn {_, pid, _, _} -> + try do + GenServer.call(pid, :get_radar, 2_000) + catch + :exit, _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + all_stocks = Enum.flat_map(radars, & &1.stocks) + + # Group by symbol so we can compute count + average target across users. + by_symbol = Enum.group_by(all_stocks, & &1["symbol"]) + + most_watched = + by_symbol + |> Enum.map(fn {symbol, stocks} -> + targets = stocks |> Enum.map(& &1["target_price"]) |> Enum.reject(&is_nil/1) + current_price = stocks |> Enum.find_value(& &1["price"]) + avg_target = if length(targets) >= @min_cohort, do: avg(targets), else: nil + + %{ + symbol: symbol, + name: stocks |> Enum.find_value(& &1["name"]), + currency: stocks |> Enum.find_value(& &1["currency"]), + watchers: length(stocks), + current_price: current_price, + avg_target_price: avg_target + } + end) + |> Enum.sort_by(&(-&1.watchers)) + |> Enum.take(@top_n) + + below_community_target = + most_watched + |> Enum.filter(fn s -> + is_number(s.current_price) and is_number(s.avg_target_price) and + s.current_price < s.avg_target_price + end) + |> Enum.map(fn s -> + Map.put(s, :percent_below, percent_below(s.current_price, s.avg_target_price)) + end) + |> Enum.sort_by(&(-&1.percent_below)) + |> Enum.take(@top_n) + + %{ + radar_count: length(radars), + total_targeted: by_symbol |> Map.keys() |> length(), + most_watched: most_watched, + below_community_target: below_community_target + } + end + + defp avg([]), do: nil + defp avg(list), do: Float.round(Enum.sum(list) / length(list), 2) + + defp percent_below(price, target) do + Float.round((target - price) / target * 100, 1) + end +end diff --git a/lib/pulse/radar_store.ex b/lib/pulse/radar_store.ex new file mode 100644 index 0000000..0de101e --- /dev/null +++ b/lib/pulse/radar_store.ex @@ -0,0 +1,145 @@ +defmodule Pulse.RadarStore do + @moduledoc """ + DETS-backed persistence for radar state. Separate file from + `Pulse.Store` (portfolios) so the two surfaces stay independent — + one can be wiped without affecting the other. + + Persisted fields are the minimum needed to rehydrate a worker: + raw `stocks` (metrics get recomputed from fresh code), `base_currency`, + and `updated_at`. Worker-side `metrics` is derived and intentionally + not persisted. + """ + use GenServer + + require Logger + + @table :pulse_radars + + # Client API + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def put(slug, state) when is_map(state) do + persistable = %{ + stocks: Map.get(state, :stocks, []), + base_currency: Map.get(state, :base_currency, "USD"), + updated_at: Map.get(state, :updated_at) + } + + GenServer.cast(__MODULE__, {:put, slug, persistable}) + end + + def get(slug) do + GenServer.call(__MODULE__, {:get, slug}) + end + + def all do + GenServer.call(__MODULE__, :all) + end + + def delete(slug) do + GenServer.cast(__MODULE__, {:delete, slug}) + end + + def restore_all do + GenServer.call(__MODULE__, :restore_all, 30_000) + end + + # Server callbacks + + @impl true + def init(_opts) do + path = Path.join(:code.priv_dir(:pulse), "data/radars.dets") |> to_charlist() + File.mkdir_p!(Path.dirname(to_string(path))) + + case :dets.open_file(@table, file: path, type: :set) do + {:ok, table} -> + Logger.info("Opened DETS table for radars: #{path}") + {:ok, %{table: table}} + + {:error, reason} -> + Logger.error("Failed to open DETS table: #{inspect(reason)}") + {:stop, reason} + end + end + + @impl true + def handle_cast({:put, slug, state}, data) do + :dets.insert(data.table, {slug, state}) + {:noreply, data} + end + + def handle_cast({:delete, slug}, data) do + :dets.delete(data.table, slug) + {:noreply, data} + end + + @impl true + def handle_call({:get, slug}, _from, data) do + result = + case :dets.lookup(data.table, slug) do + [{^slug, state}] -> state + [] -> nil + end + + {:reply, result, data} + end + + def handle_call(:all, _from, data) do + entries = + :dets.foldl( + fn {slug, state}, acc -> [{slug, state} | acc] end, + [], + data.table + ) + + {:reply, entries, data} + end + + def handle_call(:restore_all, _from, data) do + entries = + :dets.foldl( + fn {slug, state}, acc -> [{slug, state} | acc] end, + [], + data.table + ) + + restored = + Enum.reduce(entries, 0, fn {slug, state}, count -> + case Pulse.RadarSupervisor.start_worker(slug) do + {:ok, _pid} -> + restore_worker_state(slug, state) + count + 1 + + {:error, {:already_started, _pid}} -> + restore_worker_state(slug, state) + count + 1 + + {:error, reason} -> + Logger.error("Failed to restore radar worker for #{slug}: #{inspect(reason)}") + count + end + end) + + Logger.info("Restored #{restored}/#{length(entries)} radars from DETS") + {:reply, {:ok, restored}, data} + end + + defp restore_worker_state(slug, state) when is_map(state) do + payload = %{ + "stocks" => state[:stocks] || [], + "base_currency" => state[:base_currency] || "USD", + "__updated_at" => state[:updated_at] + } + + Pulse.RadarWorker.update_stocks(slug, payload) + end + + @impl true + def terminate(_reason, data) do + :dets.close(data.table) + :ok + end +end diff --git a/lib/pulse/radar_supervisor.ex b/lib/pulse/radar_supervisor.ex new file mode 100644 index 0000000..3b9ca0b --- /dev/null +++ b/lib/pulse/radar_supervisor.ex @@ -0,0 +1,36 @@ +defmodule Pulse.RadarSupervisor do + @moduledoc """ + DynamicSupervisor for `Pulse.RadarWorker` processes — mirror of + `Pulse.PortfolioSupervisor`. Workers start on `radar.opted_in` NATS + events and stop on `radar.opted_out`. + """ + use DynamicSupervisor + + require Logger + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + def start_worker(slug) do + Logger.info("Starting radar worker for slug: #{slug}") + DynamicSupervisor.start_child(__MODULE__, {Pulse.RadarWorker, slug}) + end + + def stop_worker(slug) do + case Registry.lookup(Pulse.RadarRegistry, slug) do + [{pid, _}] -> + Logger.info("Stopping radar worker for slug: #{slug}") + DynamicSupervisor.terminate_child(__MODULE__, pid) + + [] -> + Logger.warning("No radar worker found for slug: #{slug}") + {:error, :not_found} + end + end +end diff --git a/lib/pulse/radar_worker.ex b/lib/pulse/radar_worker.ex new file mode 100644 index 0000000..fdaa721 --- /dev/null +++ b/lib/pulse/radar_worker.ex @@ -0,0 +1,135 @@ +defmodule Pulse.RadarWorker do + @moduledoc """ + GenServer that maintains live radar (watchlist) state for an opted-in user. + + Mirror of `Pulse.PortfolioWorker` but simpler — radars carry per-stock + target prices, not quantities, so there's no value summation. The + RadarAggregator reads worker state to compute community-wide insights + (most-watched stocks, average targets, consensus buy signals). + + ## NATS payload (v1) + + %{ + "version" => 1, + "slug" => "alice", + "base_currency" => "USD", + "stocks" => [ + %{ + "symbol" => "AAPL", "name" => "Apple Inc.", + "currency" => "USD", "sector" => "Technology", + "price" => 178.50, "target_price" => 160.00, + "dividend_yield" => 0.56, + "fifty_two_week_high" => 199.62, + "fifty_two_week_low" => 164.08, + "ma_200" => 168.40 + }, ... + ] + } + """ + use GenServer + + require Logger + + defstruct [:slug, :updated_at, base_currency: "USD", stocks: [], metrics: %{}] + + # Client API + + def start_link(slug) do + GenServer.start_link(__MODULE__, slug, name: via(slug)) + end + + def get_radar(slug) do + GenServer.call(via(slug), :get_radar) + end + + def update_stocks(slug, payload) when is_map(payload) do + GenServer.cast(via(slug), {:update_stocks, payload}) + end + + # Server callbacks + + @impl true + def init(slug) do + Logger.info("Starting radar worker for #{slug}") + {:ok, %__MODULE__{slug: slug}} + end + + @impl true + def handle_call(:get_radar, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast({:update_stocks, payload}, state) do + stocks = payload["stocks"] || [] + base_currency = payload["base_currency"] || "USD" + + # `updated_at` should reflect a *material* radar change (add/remove a + # stock, or edit a target_price) — not every stock-price refresh that + # only mutates `price`. The restore path overrides via __updated_at. + override = payload["__updated_at"] + materially_changed = stocks_signature(stocks) != stocks_signature(state.stocks) + + updated_at = + cond do + override != nil -> override + materially_changed -> DateTime.utc_now() + true -> state.updated_at + end + + new_state = %{ + state + | stocks: stocks, + base_currency: base_currency, + metrics: compute_metrics(stocks), + updated_at: updated_at + } + + Pulse.RadarStore.put(state.slug, new_state) + + Phoenix.PubSub.broadcast( + Pulse.PubSub, + "radar:#{state.slug}", + {:radar_updated, new_state} + ) + + # Notify the radar aggregator (separate channel from the portfolio one). + Phoenix.PubSub.broadcast( + Pulse.PubSub, + "radars", + {:radar_changed, state.slug} + ) + + {:noreply, new_state} + end + + defp via(slug) do + {:via, Registry, {Pulse.RadarRegistry, slug}} + end + + defp compute_metrics(stocks) do + targeted = Enum.filter(stocks, fn s -> s["target_price"] end) + + below_target = + Enum.filter(targeted, fn s -> + price = s["price"] + target = s["target_price"] + is_number(price) and is_number(target) and price < target + end) + + %{ + stock_count: length(stocks), + targeted_count: length(targeted), + below_target_count: length(below_target) + } + end + + # Material fingerprint: what the user controls (the set of symbols and + # their target prices). Price changes from a stock-refresh shouldn't + # bump updated_at. + defp stocks_signature(stocks) do + stocks + |> Enum.map(fn s -> {s["symbol"], s["target_price"]} end) + |> Enum.sort() + end +end diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index 1ea8f32..ac49ba3 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -8,6 +8,7 @@ defmodule PulseWeb.DashboardLive do end stats = Pulse.DashboardAggregator.get_stats() + radar_stats = Pulse.RadarAggregator.get_stats() description = "Real-time community dividend portfolio dashboard. " <> @@ -30,7 +31,10 @@ defmodule PulseWeb.DashboardLive do community_sectors: Map.get(stats, :community_sectors, []), community_yoc: Map.get(stats, :community_yoc), community_current_yield: Map.get(stats, :community_current_yield), - top_visited: top_visited + top_visited: top_visited, + radar_count: radar_stats.radar_count, + most_watched: radar_stats.most_watched, + below_community_target: radar_stats.below_community_target )} end @@ -51,6 +55,18 @@ defmodule PulseWeb.DashboardLive do )} end + # Pushed by `Pulse.RadarAggregator` on each radar change (debounced). + # Keeps the same `dashboard` topic — the message type just signals + # which slice of state to update. + def handle_info({:radar_dashboard_updated, radar_stats}, socket) do + {:noreply, + assign(socket, + radar_count: radar_stats.radar_count, + most_watched: radar_stats.most_watched, + below_community_target: radar_stats.below_community_target + )} + end + @impl true def render(assigns) do ~H""" @@ -375,6 +391,77 @@ defmodule PulseWeb.DashboardLive do + + <%!-- Community Watchlist (radar aggregates) --%> +
0} class="card bg-base-100 shadow-sm border border-base-200 mt-8"> +
+
+

+ <.icon name="hero-eye" class="size-5" /> + {gettext("Community watchlist")} +

+ + {gettext("across %{count} shared radars", count: @radar_count)} + +
+ +
+ + + + + + + + + + + + + + + + + +
{gettext("Symbol")}{gettext("Watchers")}{gettext("Avg target")}{gettext("Current")}
+
{s.symbol}
+
{s.name}
+
{s.watchers} + <%= if s.avg_target_price do %> + {s.currency} {:erlang.float_to_binary(s.avg_target_price / 1, decimals: 2)} + <% else %> + + <% end %> + + <%= if s.current_price do %> + {s.currency} {:erlang.float_to_binary(s.current_price / 1, decimals: 2)} + <% else %> + + <% end %> +
+
+ + <%!-- Below-community-target sub-section: stocks the community + has consensus that current < avg_target. Highest-gap first. --%> +
+

+ <.icon name="hero-arrow-trending-down" class="size-4 text-success" /> + {gettext("Below community target")} +

+
    +
  • + {s.symbol} + + -{:erlang.float_to_binary(s.percent_below / 1, decimals: 1)}% + +
  • +
+
+
+
""" diff --git a/lib/pulse_web/live/radar_live.ex b/lib/pulse_web/live/radar_live.ex new file mode 100644 index 0000000..49e3f21 --- /dev/null +++ b/lib/pulse_web/live/radar_live.ex @@ -0,0 +1,156 @@ +defmodule PulseWeb.RadarLive do + @moduledoc """ + Public radar page at `/r/:slug`. Mirror of `PortfolioLive` but for + watchlists: shows each stock the user is tracking, their target price, + and the current price with a "X% below/above target" delta. + """ + use PulseWeb, :live_view + + @impl true + def mount(%{"slug" => slug}, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(Pulse.PubSub, "radar:#{slug}") + end + + radar = fetch_radar(slug) + + stock_count = if radar, do: length(radar.stocks), else: 0 + description = "#{slug}'s stock radar on Pulse — #{stock_count} stocks tracked" + + {:ok, + assign(socket, + page_title: "#{slug}'s Radar", + meta_description: description, + meta_url: url(~p"/r/#{slug}"), + slug: slug, + radar: radar, + has_portfolio: portfolio_exists?(slug), + not_found: is_nil(radar) + )} + end + + @impl true + def handle_info({:radar_updated, radar}, socket) do + {:noreply, assign(socket, radar: radar, not_found: false)} + end + + defp fetch_radar(slug) do + case Registry.lookup(Pulse.RadarRegistry, slug) do + [{_pid, _}] -> Pulse.RadarWorker.get_radar(slug) + [] -> nil + end + end + + defp portfolio_exists?(slug) do + case Registry.lookup(Pulse.PortfolioRegistry, slug) do + [{_pid, _}] -> true + [] -> false + end + end + + # Rendering helpers + + defp delta_class(nil, _), do: "text-base-content/40" + defp delta_class(_, nil), do: "text-base-content/40" + defp delta_class(price, target) when price < target, do: "text-success" + defp delta_class(price, target) when price > target, do: "text-error" + defp delta_class(_, _), do: "text-base-content/60" + + defp delta_label(nil, _), do: "—" + defp delta_label(_, nil), do: "—" + + defp delta_label(price, target) when is_number(price) and is_number(target) do + pct = (price - target) / target * 100 + sign = if pct >= 0, do: "+", else: "" + "#{sign}#{:erlang.float_to_binary(pct, decimals: 1)}%" + end + + defp delta_label(_, _), do: "—" + + defp format_price(nil, _), do: "—" + + defp format_price(price, currency) when is_number(price) do + "#{currency || ""} #{:erlang.float_to_binary(price / 1, decimals: 2)}" + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.icon name="hero-eye" class="size-16 mx-auto text-base-content/20 mb-4" /> +

{gettext("Radar not found")}

+

+ {gettext("The radar \"%{slug}\" doesn't exist or hasn't been shared yet.", + slug: @slug + )} +

+ <.link navigate="/" class="btn btn-primary btn-sm mt-4"> + {gettext("Back to dashboard")} + +
+ +
+
+
+

{@slug}'s {gettext("Radar")}

+

+ {gettext("%{count} stocks tracked", count: length(@radar.stocks))} +

+
+ <.link + :if={@has_portfolio} + navigate={~p"/p/#{@slug}"} + class="btn btn-ghost btn-sm" + > + <.icon name="hero-briefcase" class="size-4" /> + {gettext("View portfolio")} + +
+ +
+

{gettext("This radar is empty.")}

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
{gettext("Symbol")}{gettext("Sector")}{gettext("Price")}{gettext("Target")}{gettext("Delta")}{gettext("Yield")}
+
{s["symbol"]}
+
{s["name"]}
+
{s["sector"]}{format_price(s["price"], s["currency"])} + {format_price(s["target_price"], s["currency"])} + + {delta_label(s["price"], s["target_price"])} + + <%= if s["dividend_yield"] do %> + {:erlang.float_to_binary(s["dividend_yield"] / 1, decimals: 2)}% + <% else %> + — + <% end %> +
+
+
+
+ """ + end +end diff --git a/lib/pulse_web/router.ex b/lib/pulse_web/router.ex index 67d6ff0..2dd9456 100644 --- a/lib/pulse_web/router.ex +++ b/lib/pulse_web/router.ex @@ -23,6 +23,7 @@ defmodule PulseWeb.Router do live_session :default, on_mount: [PulseWeb.Live.Hooks.SetLocale] do live "/", DashboardLive, :index live "/p/:slug", PortfolioLive, :show + live "/r/:slug", RadarLive, :show end end diff --git a/test/pulse/radar_aggregator_test.exs b/test/pulse/radar_aggregator_test.exs new file mode 100644 index 0000000..6e316d1 --- /dev/null +++ b/test/pulse/radar_aggregator_test.exs @@ -0,0 +1,58 @@ +defmodule Pulse.RadarAggregatorTest do + use ExUnit.Case, async: false + + setup do + for {_, pid, _, _} <- DynamicSupervisor.which_children(Pulse.RadarSupervisor) do + DynamicSupervisor.terminate_child(Pulse.RadarSupervisor, pid) + end + + Pulse.RadarAggregator.refresh() + :ok + end + + test "returns zero state when no radars are shared" do + stats = Pulse.RadarAggregator.refresh() + assert stats.radar_count == 0 + assert stats.most_watched == [] + assert stats.below_community_target == [] + end + + test "aggregates watchers across radars and suppresses avg below MIN_COHORT" do + # 2 radars include AAPL with a target — below the cohort threshold (3), + # so avg_target_price should stay nil. Symbol still appears in + # most_watched with watchers=2. + seed_radar("alice", [%{"symbol" => "AAPL", "price" => 180.0, "target_price" => 160.0}]) + seed_radar("bob", [%{"symbol" => "AAPL", "price" => 180.0, "target_price" => 170.0}]) + Process.sleep(50) + + stats = Pulse.RadarAggregator.refresh() + aapl = Enum.find(stats.most_watched, fn s -> s.symbol == "AAPL" end) + assert aapl != nil + assert aapl.watchers == 2 + # below MIN_COHORT = 3 + assert aapl.avg_target_price == nil + end + + test "computes avg_target_price + below_community_target when cohort >= 3" do + # 3 users target AAPL: 160, 170, 180 → avg 170. Current 150 → below target. + seed_radar("alice", [%{"symbol" => "AAPL", "price" => 150.0, "target_price" => 160.0}]) + seed_radar("bob", [%{"symbol" => "AAPL", "price" => 150.0, "target_price" => 170.0}]) + seed_radar("carol", [%{"symbol" => "AAPL", "price" => 150.0, "target_price" => 180.0}]) + Process.sleep(50) + + stats = Pulse.RadarAggregator.refresh() + aapl = Enum.find(stats.most_watched, fn s -> s.symbol == "AAPL" end) + assert aapl.avg_target_price == 170.0 + assert aapl.current_price == 150.0 + + below = Enum.find(stats.below_community_target, fn s -> s.symbol == "AAPL" end) + assert below != nil + # (170 - 150) / 170 * 100 ≈ 11.76 → rounded to 11.8 + assert below.percent_below == 11.8 + end + + defp seed_radar(slug, stocks) do + {:ok, _} = Pulse.RadarSupervisor.start_worker(slug) + Pulse.RadarWorker.update_stocks(slug, %{"stocks" => stocks}) + end +end diff --git a/test/pulse/radar_worker_test.exs b/test/pulse/radar_worker_test.exs new file mode 100644 index 0000000..8bd0ab2 --- /dev/null +++ b/test/pulse/radar_worker_test.exs @@ -0,0 +1,81 @@ +defmodule Pulse.RadarWorkerTest do + use ExUnit.Case, async: false + + setup do + for {_, pid, _, _} <- DynamicSupervisor.which_children(Pulse.RadarSupervisor) do + DynamicSupervisor.terminate_child(Pulse.RadarSupervisor, pid) + end + + :ok + end + + test "starts with empty state" do + {:ok, _pid} = Pulse.RadarSupervisor.start_worker("test-empty-radar") + + radar = Pulse.RadarWorker.get_radar("test-empty-radar") + assert radar.slug == "test-empty-radar" + assert radar.stocks == [] + assert radar.metrics == %{} + end + + test "update_stocks stores stocks and computes basic metrics" do + {:ok, _pid} = Pulse.RadarSupervisor.start_worker("test-radar-metrics") + + Pulse.RadarWorker.update_stocks("test-radar-metrics", %{ + "stocks" => [ + %{"symbol" => "AAPL", "price" => 178.50, "target_price" => 160.0}, + %{"symbol" => "KO", "price" => 55.0, "target_price" => 60.0}, + %{"symbol" => "WATCH", "price" => 100.0} + ] + }) + + Process.sleep(50) + + radar = Pulse.RadarWorker.get_radar("test-radar-metrics") + assert radar.metrics.stock_count == 3 + assert radar.metrics.targeted_count == 2 + # KO is below its target (55 < 60); AAPL is above (178.5 > 160). + assert radar.metrics.below_target_count == 1 + end + + test "updated_at advances only on material changes (not on stock price refresh)" do + {:ok, _pid} = Pulse.RadarSupervisor.start_worker("test-radar-mtime") + + Pulse.RadarWorker.update_stocks("test-radar-mtime", %{ + "stocks" => [%{"symbol" => "AAPL", "price" => 178.50, "target_price" => 160.0}] + }) + + Process.sleep(50) + first = Pulse.RadarWorker.get_radar("test-radar-mtime").updated_at + assert first != nil + + # Stock-price refresh only: same symbol + same target, different price. + Pulse.RadarWorker.update_stocks("test-radar-mtime", %{ + "stocks" => [%{"symbol" => "AAPL", "price" => 200.00, "target_price" => 160.0}] + }) + + Process.sleep(50) + second = Pulse.RadarWorker.get_radar("test-radar-mtime").updated_at + assert second == first, "stock-price refresh shouldn't bump updated_at" + + # User actually edited a target → updated_at should advance. + Pulse.RadarWorker.update_stocks("test-radar-mtime", %{ + "stocks" => [%{"symbol" => "AAPL", "price" => 200.00, "target_price" => 150.0}] + }) + + Process.sleep(50) + third = Pulse.RadarWorker.get_radar("test-radar-mtime").updated_at + assert DateTime.compare(third, first) == :gt + end + + test "broadcasts radar: on PubSub when state changes" do + Phoenix.PubSub.subscribe(Pulse.PubSub, "radar:test-broadcast") + {:ok, _pid} = Pulse.RadarSupervisor.start_worker("test-broadcast") + + Pulse.RadarWorker.update_stocks("test-broadcast", %{ + "stocks" => [%{"symbol" => "AAPL", "price" => 150.0, "target_price" => 140.0}] + }) + + assert_receive {:radar_updated, %Pulse.RadarWorker{slug: "test-broadcast"}}, 1_000 + end +end From bea2a4799fe7781bbdbe467dd7c4d600cbe48717 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 10:10:59 +0200 Subject: [PATCH 2/8] Polish radar surfaces: match portfolio look, cross-link both ways, dashboard reorg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the round-of-feedback on the radar PR: 1. **Bidirectional cross-link**. Portfolio page now shows a "View radar" link when a RadarWorker exists for the same slug (mirror of the existing "View portfolio" link from RadarLive). 2. **RadarLive visual overhaul**. Same capturable card / branding footer / button row as PortfolioLive (Share Image + Share Link hooks both wired). Differentiated where it matters: indigo avatar with an eye icon vs the portfolio's primary-colored initial, "· Radar" suffix on the title, three summary stat cards (stocks tracked / with target / below target). Stocks table includes per-stock logos like PortfolioLive's grid. 3. **Visit tracking** via new `Pulse.RadarAnalytics` — parallel of `Pulse.Analytics` (separate ETS + DETS tables so portfolio and radar slugs don't conflate counts). Wired into RadarLive.mount and the radar.opted_out NATS handler for cleanup. 4. **Dashboard reorganization**: - "Portfolios" section heading + the existing trio (stats cards / three-column lists / yield cards / sectors) grouped together - New "Radars" section below: a fresh trio of stat cards (Shared Radars / Stocks Tracked / Below Community Target) + a three-column grid (Community Watchlist / Recently Updated / Trending This Week) + a "Below Community Target" strip across the bottom - "How It Works" moved to the very end (was previously sandwiched between the portfolio and community-watchlist content because of a misplaced nested div from the earlier commit) - `RadarAggregator.stats` extended with `recent_radars` (sorted by updated_at, top-N). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/pulse/application.ex | 1 + lib/pulse/nats/consumer.ex | 1 + lib/pulse/radar_analytics.ex | 126 +++++++++++ lib/pulse_web/live/dashboard_live.ex | 304 ++++++++++++++++++++------- lib/pulse_web/live/portfolio_live.ex | 17 ++ lib/pulse_web/live/radar_live.ex | 218 ++++++++++++++----- 6 files changed, 531 insertions(+), 136 deletions(-) create mode 100644 lib/pulse/radar_analytics.ex diff --git a/lib/pulse/application.ex b/lib/pulse/application.ex index 749acde..4cd869b 100644 --- a/lib/pulse/application.ex +++ b/lib/pulse/application.ex @@ -20,6 +20,7 @@ defmodule Pulse.Application do {Registry, keys: :unique, name: Pulse.RadarRegistry}, {Pulse.RadarSupervisor, []}, {Pulse.RadarStore, []}, + {Pulse.RadarAnalytics, []}, # Aggregators (must start before NATS consumer so they're subscribed) Pulse.DashboardAggregator, diff --git a/lib/pulse/nats/consumer.ex b/lib/pulse/nats/consumer.ex index 4a52f3c..6c40388 100644 --- a/lib/pulse/nats/consumer.ex +++ b/lib/pulse/nats/consumer.ex @@ -148,6 +148,7 @@ defmodule Pulse.Nats.Consumer do defp handle_event("radar.opted_out", %{"slug" => slug}) do Logger.info("Radar opted out: #{slug}") Pulse.RadarStore.delete(slug) + Pulse.RadarAnalytics.delete(slug) Pulse.RadarSupervisor.stop_worker(slug) end diff --git a/lib/pulse/radar_analytics.ex b/lib/pulse/radar_analytics.ex new file mode 100644 index 0000000..ec7ba44 --- /dev/null +++ b/lib/pulse/radar_analytics.ex @@ -0,0 +1,126 @@ +defmodule Pulse.RadarAnalytics do + @moduledoc """ + Tracks visits to `/r/:slug` radar pages with weekly rollover. Parallel + to `Pulse.Analytics` (portfolio visits) — separate ETS + DETS tables so + the two visit namespaces don't collide on shared slugs. + """ + use GenServer + + require Logger + + @ets_table :pulse_radar_analytics + @dets_table :pulse_radar_analytics_dets + + # Client API + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Record a visit to a radar page." + def track_visit(slug) do + GenServer.cast(__MODULE__, {:track_visit, slug}) + end + + @doc "Return the top N most visited radars this week." + def top_visited(limit \\ 5) do + GenServer.call(__MODULE__, {:top_visited, limit}) + end + + @doc "Remove a slug (e.g. on opt-out)." + def delete(slug) do + GenServer.cast(__MODULE__, {:delete, slug}) + end + + # Server callbacks + + @impl true + def init(_opts) do + :ets.new(@ets_table, [:named_table, :public, :set]) + + data_dir = Application.get_env(:pulse, :data_dir, "priv/data") + File.mkdir_p!(data_dir) + dets_path = Path.join(data_dir, "radar_analytics.dets") |> String.to_charlist() + + case :dets.open_file(@dets_table, file: dets_path, type: :set) do + {:ok, _} -> + restore_from_dets() + schedule_weekly_reset() + {:ok, %{week: current_week()}} + + {:error, reason} -> + Logger.error("Failed to open radar analytics DETS: #{inspect(reason)}") + {:stop, reason} + end + end + + @impl true + def handle_cast({:track_visit, slug}, state) do + count = :ets.update_counter(@ets_table, slug, {2, 1}, {slug, 0}) + :dets.insert(@dets_table, {slug, count}) + {:noreply, state} + end + + @impl true + def handle_cast({:delete, slug}, state) do + :ets.delete(@ets_table, slug) + :dets.delete(@dets_table, slug) + {:noreply, state} + end + + @impl true + def handle_call({:top_visited, limit}, _from, state) do + results = + :ets.tab2list(@ets_table) + |> Enum.sort_by(fn {_slug, count} -> -count end) + |> Enum.take(limit) + |> Enum.map(fn {slug, count} -> %{slug: slug, visits: count} end) + + {:reply, results, state} + end + + @impl true + def handle_info(:weekly_reset, state) do + new_week = current_week() + + if new_week != state.week do + Logger.info("Radar analytics weekly reset (week #{new_week})") + :ets.delete_all_objects(@ets_table) + :dets.delete_all_objects(@dets_table) + end + + schedule_weekly_reset() + {:noreply, %{state | week: new_week}} + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + @impl true + def terminate(_reason, _state) do + :dets.close(@dets_table) + :ok + end + + # Private + + defp restore_from_dets do + :dets.foldl( + fn {slug, count}, _acc -> + :ets.insert(@ets_table, {slug, count}) + :ok + end, + :ok, + @dets_table + ) + end + + defp current_week do + :calendar.iso_week_number(Date.utc_today() |> Date.to_erl()) + end + + defp schedule_weekly_reset do + Process.send_after(self(), :weekly_reset, :timer.hours(1)) + end +end diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index ac49ba3..6fc5118 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -15,6 +15,7 @@ defmodule PulseWeb.DashboardLive do "#{stats.portfolio_count} portfolios tracking #{stats.total_holdings} holdings." top_visited = Pulse.Analytics.top_visited(10) + top_visited_radars = Pulse.RadarAnalytics.top_visited(10) {:ok, assign(socket, @@ -33,8 +34,11 @@ defmodule PulseWeb.DashboardLive do community_current_yield: Map.get(stats, :community_current_yield), top_visited: top_visited, radar_count: radar_stats.radar_count, + total_targeted: radar_stats.total_targeted, most_watched: radar_stats.most_watched, - below_community_target: radar_stats.below_community_target + below_community_target: radar_stats.below_community_target, + recent_radars: Map.get(radar_stats, :recent_radars, []), + top_visited_radars: top_visited_radars )} end @@ -62,8 +66,10 @@ defmodule PulseWeb.DashboardLive do {:noreply, assign(socket, radar_count: radar_stats.radar_count, + total_targeted: radar_stats.total_targeted, most_watched: radar_stats.most_watched, - below_community_target: radar_stats.below_community_target + below_community_target: radar_stats.below_community_target, + recent_radars: Map.get(radar_stats, :recent_radars, []) )} end @@ -91,8 +97,15 @@ defmodule PulseWeb.DashboardLive do

+ <%!-- ─── Portfolio group ───────────────────────────────────── --%> +
+

+ <.icon name="hero-briefcase" class="size-4" /> + {gettext("Portfolios")} +

+ <%!-- Stats Cards --%> -
+
@@ -150,7 +163,7 @@ defmodule PulseWeb.DashboardLive do
-
+
<%!-- Popular Stocks --%>
@@ -279,7 +292,7 @@ defmodule PulseWeb.DashboardLive do <%!-- Community yield cards (gated by cohort threshold same as total_value) --%>
<%!-- Community Sectors (gated by cohort threshold same as total_value) --%> -
+
<.icon name="hero-chart-pie" class="size-5 text-blue-500" /> @@ -349,8 +362,212 @@ defmodule PulseWeb.DashboardLive do
- <%!-- How It Works --%> -
+
+ + <%!-- ─── Radar group ───────────────────────────────────────── --%> +
0} class="mb-12"> +

+ <.icon name="hero-eye" class="size-4" /> + {gettext("Radars")} +

+ + <%!-- Radar stats cards (parallel to the portfolio trio above). --%> +
+
+
+
+
+ <.icon name="hero-eye" class="size-5 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {gettext("Shared Radars")} +

+

{@radar_count}

+
+
+
+
+ +
+
+
+
+ <.icon name="hero-bullseye" class="size-5 text-violet-600 dark:text-violet-400" /> +
+
+

+ {gettext("Stocks Tracked")} +

+

{@total_targeted}

+
+
+
+
+ +
+
+
+
+ <.icon name="hero-arrow-trending-down" class="size-5 text-success" /> +
+
+

+ {gettext("Below Community Target")} +

+

{length(@below_community_target)}

+
+
+
+
+
+ + <%!-- 3-col grid mirrors the portfolio layout: Community Watchlist / + Recent Radars / Trending Radars. --%> +
+ <%!-- Community Watchlist (top-watched stocks across radars) --%> +
+
+
+ <.icon name="hero-bullseye" class="size-5 text-violet-500" /> +

{gettext("Community Watchlist")}

+
+
+ <.icon name="hero-eye-slash" class="size-12 mx-auto text-base-content/20 mb-3" /> +

{gettext("No stocks tracked yet")}

+
+
+
+ + {idx + 1} + + <.stock_logo symbol={s.symbol} size={32} /> + {s.symbol} +
+ <.icon name="hero-eye-micro" class="size-3.5" /> + {s.watchers} +
+
+
+
+
+ + <%!-- Recently Updated Radars --%> +
+
+
+ <.icon name="hero-eye" class="size-5 text-indigo-500" /> +

{gettext("Recently Updated")}

+
+
+ <.icon name="hero-eye" class="size-12 mx-auto text-base-content/20 mb-3" /> +

{gettext("No radars shared yet")}

+
+
+ <.link + :for={r <- @recent_radars} + navigate={~p"/r/#{r.slug}"} + class="flex items-center gap-2 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-indigo-500/10 transition-colors group" + > + + <.icon name="hero-eye" class="size-4" /> + + + {r.slug} + + + {relative_time(r.updated_at)} + + <.icon + name="hero-arrow-right-micro" + class="size-4 text-base-content/30 group-hover:text-indigo-500 transition-colors" + /> + +
+
+
+ + <%!-- Trending Radars (most visited this week) --%> +
+
+
+ <.icon name="hero-fire" class="size-5 text-orange-500" /> +

{gettext("Trending This Week")}

+
+
+ <.icon name="hero-eye" class="size-12 mx-auto text-base-content/20 mb-3" /> +

{gettext("No visits yet")}

+

+ {gettext("Visit a radar to see it here")} +

+
+
+ <.link + :for={{entry, idx} <- Enum.with_index(@top_visited_radars)} + navigate={~p"/r/#{entry.slug}"} + class="flex items-center gap-3 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-orange-500/10 transition-colors group" + > + + {idx + 1} + + + <.icon name="hero-eye" class="size-4" /> + + + {entry.slug} + + + <.icon name="hero-eye-micro" class="size-3.5" /> + {entry.visits} + + +
+
+
+
+ + <%!-- Below community target — full-width strip below the 3-col grid. --%> +
+
+
+ <.icon name="hero-arrow-trending-down" class="size-5 text-success" /> +

{gettext("Below Community Target")}

+ + {gettext("consensus buy signal")} + +
+
    +
  • + <.stock_logo symbol={s.symbol} size={28} /> +
    +

    {s.symbol}

    +

    + -{:erlang.float_to_binary(s.percent_below / 1, decimals: 1)}% +

    +
    +
  • +
+
+
+
+ + <%!-- ─── How It Works (footer, always last) ───────────────── --%> +

{gettext("How It Works")}

@@ -391,77 +608,6 @@ defmodule PulseWeb.DashboardLive do
- - <%!-- Community Watchlist (radar aggregates) --%> -
0} class="card bg-base-100 shadow-sm border border-base-200 mt-8"> -
-
-

- <.icon name="hero-eye" class="size-5" /> - {gettext("Community watchlist")} -

- - {gettext("across %{count} shared radars", count: @radar_count)} - -
- -
- - - - - - - - - - - - - - - - - -
{gettext("Symbol")}{gettext("Watchers")}{gettext("Avg target")}{gettext("Current")}
-
{s.symbol}
-
{s.name}
-
{s.watchers} - <%= if s.avg_target_price do %> - {s.currency} {:erlang.float_to_binary(s.avg_target_price / 1, decimals: 2)} - <% else %> - - <% end %> - - <%= if s.current_price do %> - {s.currency} {:erlang.float_to_binary(s.current_price / 1, decimals: 2)} - <% else %> - - <% end %> -
-
- - <%!-- Below-community-target sub-section: stocks the community - has consensus that current < avg_target. Highest-gap first. --%> -
-

- <.icon name="hero-arrow-trending-down" class="size-4 text-success" /> - {gettext("Below community target")} -

-
    -
  • - {s.symbol} - - -{:erlang.float_to_binary(s.percent_below / 1, decimals: 1)}% - -
  • -
-
-
-
""" diff --git a/lib/pulse_web/live/portfolio_live.ex b/lib/pulse_web/live/portfolio_live.ex index 9dc897b..b226a24 100644 --- a/lib/pulse_web/live/portfolio_live.ex +++ b/lib/pulse_web/live/portfolio_live.ex @@ -22,6 +22,7 @@ defmodule PulseWeb.PortfolioLive do meta_url: url(~p"/p/#{slug}"), slug: slug, portfolio: portfolio, + has_radar: radar_exists?(slug), not_found: is_nil(portfolio) )} end @@ -38,6 +39,15 @@ defmodule PulseWeb.PortfolioLive do end end + # Cross-link gating: only show the "View radar" button if a RadarWorker + # actually exists for this slug. + defp radar_exists?(slug) do + case Registry.lookup(Pulse.RadarRegistry, slug) do + [{_pid, _}] -> true + [] -> false + end + end + @impl true def render(assigns) do ~H""" @@ -58,6 +68,13 @@ defmodule PulseWeb.PortfolioLive do
<%!-- Navigation --%>
+ <.link + :if={@has_radar} + navigate={~p"/r/#{@slug}"} + class="btn btn-ghost btn-sm" + > + <.icon name="hero-eye" class="size-4" /> {gettext("View radar")} +
-
-
-

{@slug}'s {gettext("Radar")}

-

- {gettext("%{count} stocks tracked", count: length(@radar.stocks))} -

-
+ <%!-- Navigation: matches PortfolioLive ordering exactly. --%> +
<.link :if={@has_portfolio} navigate={~p"/p/#{@slug}"} @@ -106,48 +123,135 @@ defmodule PulseWeb.RadarLive do <.icon name="hero-briefcase" class="size-4" /> {gettext("View portfolio")} + + + <.link navigate="/" class="btn btn-ghost btn-sm"> + <.icon name="hero-arrow-left-micro" class="size-4" /> {gettext("Dashboard")} +
-
-

{gettext("This radar is empty.")}

-
+ <%!-- Capturable card — id="portfolio-capture" so the SaveImage hook + (which targets that id) works without duplication. --%> +
+ <%!-- Header: avatar + slug + summary. The avatar uses a different + accent (eye icon + indigo) so screenshots are distinguishable + from portfolios at a glance. --%> +
+ + <.icon name="hero-eye" class="size-6" /> + +
+

+ {@slug} + · {gettext("Radar")} +

+

+ {ngettext("1 stock tracked", "%{count} stocks tracked", length(@radar.stocks))} +

+
+
+ + <%!-- Radar summary stats: total + targeted + below-target count. --%> +
0} class="mb-6 grid grid-cols-3 gap-3"> +
+
+

{gettext("Stocks tracked")}

+

{length(@radar.stocks)}

+
+
+
+
+

{gettext("With target")}

+

+ {Enum.count(@radar.stocks, fn s -> s["target_price"] end)} +

+
+
+
+
+

{gettext("Below target")}

+

+ {below_target_count(@radar.stocks)} +

+
+
+
-
- - - - - - - - - - - - - - - - - - - - - -
{gettext("Symbol")}{gettext("Sector")}{gettext("Price")}{gettext("Target")}{gettext("Delta")}{gettext("Yield")}
-
{s["symbol"]}
-
{s["name"]}
-
{s["sector"]}{format_price(s["price"], s["currency"])} - {format_price(s["target_price"], s["currency"])} - - {delta_label(s["price"], s["target_price"])} - - <%= if s["dividend_yield"] do %> - {:erlang.float_to_binary(s["dividend_yield"] / 1, decimals: 2)}% - <% else %> - — - <% end %> -
+ <%!-- Stocks table — sortable view of the radar. --%> +
+ + + + + + + + + + + + + + + + + + + + + +
{gettext("Symbol")}{gettext("Sector")}{gettext("Price")}{gettext("Target")}{gettext("Delta")}{gettext("Yield")}
+
+ <.stock_logo symbol={s["symbol"]} size={28} /> +
+
{s["symbol"]}
+
{s["name"]}
+
+
+
{s["sector"]}{format_price(s["price"], s["currency"])} + {format_price(s["target_price"], s["currency"])} + + {delta_label(s["price"], s["target_price"])} + + {format_yield(s["dividend_yield"])} +
+
+ + <%!-- Empty state --%> +
+ <.icon name="hero-eye-slash" class="size-12 mx-auto text-base-content/20 mb-3" /> +

{gettext("This radar is empty.")}

+
+ + <%!-- Branding footer for screenshot — same as PortfolioLive. --%> +
+ + pulse.quantic.es +
From f07133fcdd529404f26468c89058d79653651a7e Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 10:31:08 +0200 Subject: [PATCH 3/8] Radar: visual mini-card grid sorted by target-delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the radar's stocks table with a portfolio-style mini-card grid (same `capture-grid` columns, same logo placement, same card structure) so the radar page feels at home next to the portfolio. Each card surfaces what matters on a radar: - The stock logo - The symbol - A color-coded delta (green when below target, red when above, neutral at target, muted when no target set) with a matching dot - Current price / target price line underneath, monospace Sort order is what makes the page actionable: most-below-target first (biggest opportunity → green stripe up top), then at-target, then above-target, then no-target last. Within each band sorted by absolute gap so the most striking signal leads. Dropped the sector column entirely — it's not actionable on a radar. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/pulse_web/live/radar_live.ex | 130 ++++++++++++++++++------------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/lib/pulse_web/live/radar_live.ex b/lib/pulse_web/live/radar_live.ex index c0a1752..0974489 100644 --- a/lib/pulse_web/live/radar_live.ex +++ b/lib/pulse_web/live/radar_live.ex @@ -52,7 +52,6 @@ defmodule PulseWeb.RadarLive do end # Per-stock status: below_target / above_target / at_target / unknown. - # Drives the color of the delta column. defp status_for(price, target) when is_number(price) and is_number(target) do cond do price < target -> :below_target @@ -63,38 +62,68 @@ defmodule PulseWeb.RadarLive do defp status_for(_, _), do: :unknown - defp status_color(:below_target), do: "text-success" - defp status_color(:above_target), do: "text-error" - defp status_color(:at_target), do: "text-base-content/60" - defp status_color(:unknown), do: "text-base-content/40" + # Mini-card text color for the delta number. + defp status_text_color(:below_target), do: "text-success" + defp status_text_color(:above_target), do: "text-error" + defp status_text_color(:at_target), do: "text-base-content/70" + defp status_text_color(:unknown), do: "text-base-content/40" - defp delta_label(price, target) when is_number(price) and is_number(target) do - pct = (price - target) / target * 100 + # Mini-card status dot background. + defp status_dot_color(:below_target), do: "bg-success" + defp status_dot_color(:above_target), do: "bg-error" + defp status_dot_color(:at_target), do: "bg-base-content/40" + defp status_dot_color(:unknown), do: "bg-base-300" + + # Subtle card-border tint that reinforces the status colour without + # overpowering the grid. + defp status_border(:below_target), do: "border-success/30" + defp status_border(:above_target), do: "border-error/30" + defp status_border(_), do: "border-base-300" + + # Delta as % between current price and target. Negative = below target + # (opportunity), positive = above target. + defp delta_pct(price, target) when is_number(price) and is_number(target) and target != 0 do + (price - target) / target * 100 + end + + defp delta_pct(_, _), do: nil + + defp delta_label(nil), do: "—" + + defp delta_label(pct) when is_number(pct) do sign = if pct >= 0, do: "+", else: "" "#{sign}#{:erlang.float_to_binary(pct, decimals: 1)}%" end - defp delta_label(_, _), do: "—" - defp format_price(nil, _), do: "—" defp format_price(price, currency) when is_number(price) do "#{currency || ""} #{:erlang.float_to_binary(price / 1, decimals: 2)}" end - defp format_yield(nil), do: "—" - - defp format_yield(y) when is_number(y), - do: "#{:erlang.float_to_binary(y * 1.0, decimals: 2)}%" - - defp format_yield(_), do: "—" - defp below_target_count(stocks) do Enum.count(stocks, fn s -> status_for(s["price"], s["target_price"]) == :below_target end) end + # Sort order for the radar grid: most-below-target first (biggest + # opportunity), then at-target, then above-target, then stocks without + # a target last. Within each band, sort by absolute delta (largest + # gap first) so the most striking cards lead. + defp sorted_for_grid(stocks) do + Enum.sort_by(stocks, fn s -> + pct = delta_pct(s["price"], s["target_price"]) + + cond do + pct == nil -> {3, 0} + pct < 0 -> {0, pct} + pct == 0 -> {1, 0} + pct > 0 -> {2, pct} + end + end) + end + @impl true def render(assigns) do ~H""" @@ -201,44 +230,37 @@ defmodule PulseWeb.RadarLive do
- <%!-- Stocks table — sortable view of the radar. --%> -
- - - - - - - - - - - - - - - - - - - - - -
{gettext("Symbol")}{gettext("Sector")}{gettext("Price")}{gettext("Target")}{gettext("Delta")}{gettext("Yield")}
-
- <.stock_logo symbol={s["symbol"]} size={28} /> -
-
{s["symbol"]}
-
{s["name"]}
-
-
-
{s["sector"]}{format_price(s["price"], s["currency"])} - {format_price(s["target_price"], s["currency"])} - - {delta_label(s["price"], s["target_price"])} - - {format_yield(s["dividend_yield"])} -
+ <%!-- Stock mini-cards: sorted by delta-from-target so the best + opportunities (most-below-target → green) are first, then + at-target, then above-target, then stocks without a target. --%> +
+
status_border(status_for(s["price"], s["target_price"]))} + > +
+
+ <.stock_logo symbol={s["symbol"]} /> +
+

{s["symbol"]}

+
+
status_dot_color(status_for(s["price"], s["target_price"]))}> +
+ status_text_color(status_for(s["price"], s["target_price"]))}> + {delta_label(delta_pct(s["price"], s["target_price"]))} + +
+

+ {format_price(s["price"], s["currency"])} + + / {format_price(s["target_price"], s["currency"])} + +

+
+
<%!-- Empty state --%> From f2798841932a4d45bbb86385ac126e1769bbc141 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 10:34:45 +0200 Subject: [PATCH 4/8] Radar: denser mini-cards for long watchlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radars can easily run 50+ stocks (way longer than a portfolio's typical 5-15 holdings), so the portfolio-equivalent card density was too tall. Trim: - Card padding p-4 → p-2 - Logo size: default → 28px - Symbol text-sm → text-xs - Status dot w-2.5 → w-1.5 - Delta text-lg → text-xs - Drop the price/target line entirely from card body; surface it via the card's `title` attribute (hover tooltip) so the info is still one mouse-over away when wanted. - Grid: 2/3/4/5 cols → 3/4/6/8 cols at base/sm/md/lg Net result: ~half the previous card height. A 60-stock radar now fits in a couple of screens instead of needing a scroll marathon. Sort order and color scheme unchanged — still most-below-target first with green/red dots. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/pulse_web/live/radar_live.ex | 42 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/pulse_web/live/radar_live.ex b/lib/pulse_web/live/radar_live.ex index 0974489..de60dd8 100644 --- a/lib/pulse_web/live/radar_live.ex +++ b/lib/pulse_web/live/radar_live.ex @@ -124,6 +124,19 @@ defmodule PulseWeb.RadarLive do end) end + # Compact "current / target" string surfaced via the card's title + # attribute (hover tooltip) so we keep the card itself tight. + defp tooltip_for(s) do + parts = ["#{s["symbol"]} — #{format_price(s["price"], s["currency"])}"] + + parts = + if s["target_price"], + do: parts ++ ["target #{format_price(s["target_price"], s["currency"])}"], + else: parts + + Enum.join(parts, " · ") + end + @impl true def render(assigns) do ~H""" @@ -232,33 +245,32 @@ defmodule PulseWeb.RadarLive do <%!-- Stock mini-cards: sorted by delta-from-target so the best opportunities (most-below-target → green) are first, then - at-target, then above-target, then stocks without a target. --%> + at-target, then above-target, then stocks without a target. + + Denser than the portfolio grid because radars can be 50+ + stocks. Price + target are in the title attribute (hover + tooltip) rather than printed, to keep card height tight. --%>
status_border(status_for(s["price"], s["target_price"]))} + title={tooltip_for(s)} > -
-
- <.stock_logo symbol={s["symbol"]} /> +
+
+ <.stock_logo symbol={s["symbol"]} size={28} />
-

{s["symbol"]}

-
-
status_dot_color(status_for(s["price"], s["target_price"]))}> +

{s["symbol"]}

+
+
status_dot_color(status_for(s["price"], s["target_price"]))}>
- status_text_color(status_for(s["price"], s["target_price"]))}> + status_text_color(status_for(s["price"], s["target_price"]))}> {delta_label(delta_pct(s["price"], s["target_price"]))}
-

- {format_price(s["price"], s["currency"])} - - / {format_price(s["target_price"], s["currency"])} - -

From e7c9fbfa6f1bbc0b2ab041ee9b04e2306f805823 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 10:37:57 +0200 Subject: [PATCH 5/8] Radar cards: real info-icon affordance + tap-friendly details overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version put price + target in the card's `title` attribute, which is invisible on touch devices and undiscoverable even on desktop. Replace with: - A small information-circle icon in the top-right corner of each card — visible signal that more is available. - Tapping/clicking the icon toggles a detail overlay covering the card with Price / Target labels and values. Works identically on mobile (tap) and desktop (click). - A close ✕ in the overlay so the dismiss target is obvious. Tapping the info icon again also closes it (it's the same `JS.toggle_class` binding). Uses `Phoenix.LiveView.JS.toggle_class`/`add_class` so the whole interaction is client-side — no server roundtrip per tap. Symbols may contain dots (e.g. `REP.MC`); we replace `.` with `-` in the DOM id so the `to: "#..."` selector doesn't trip on CSS escaping. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/pulse_web/live/radar_live.ex | 63 ++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/lib/pulse_web/live/radar_live.ex b/lib/pulse_web/live/radar_live.ex index de60dd8..eaf54e6 100644 --- a/lib/pulse_web/live/radar_live.ex +++ b/lib/pulse_web/live/radar_live.ex @@ -8,6 +8,8 @@ defmodule PulseWeb.RadarLive do """ use PulseWeb, :live_view + alias Phoenix.LiveView.JS + @impl true def mount(%{"slug" => slug}, _session, socket) do if connected?(socket) do @@ -124,18 +126,10 @@ defmodule PulseWeb.RadarLive do end) end - # Compact "current / target" string surfaced via the card's title - # attribute (hover tooltip) so we keep the card itself tight. - defp tooltip_for(s) do - parts = ["#{s["symbol"]} — #{format_price(s["price"], s["currency"])}"] - - parts = - if s["target_price"], - do: parts ++ ["target #{format_price(s["target_price"], s["currency"])}"], - else: parts - - Enum.join(parts, " · ") - end + # CSS-safe DOM id from a ticker. Symbols may contain dots (e.g. REP.MC); + # we replace them with hyphens so the resulting `to: "#..."` selector + # used by phx-click works without escaping. + defp detail_id(symbol), do: "rd-#{String.replace(symbol, ".", "-")}" @impl true def render(assigns) do @@ -248,17 +242,27 @@ defmodule PulseWeb.RadarLive do at-target, then above-target, then stocks without a target. Denser than the portfolio grid because radars can be 50+ - stocks. Price + target are in the title attribute (hover - tooltip) rather than printed, to keep card height tight. --%> + stocks. Each card has a small info icon (top-right) that + toggles an overlay panel with the price + target. Works on + tap (mobile) and click (desktop) — no `title` tooltip so + touch devices have a real affordance. --%>
status_border(status_for(s["price"], s["target_price"]))} - title={tooltip_for(s)} + class={"relative card bg-base-200 border " <> status_border(status_for(s["price"], s["target_price"]))} > + +
<.stock_logo symbol={s["symbol"]} size={28} /> @@ -272,6 +276,33 @@ defmodule PulseWeb.RadarLive do
+ + <%!-- Detail overlay — tap the icon to toggle, tap close to dismiss. --%> +
From fe5ac5918cd2e1ed1256f5075e2e3ebff25a069e Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 25 May 2026 10:38:48 +0200 Subject: [PATCH 6/8] Radar card icons: cursor-pointer on hover Without `.btn` classes the plain
@@ -166,7 +171,7 @@ defmodule PulseWeb.PortfolioLive do class="flex items-center gap-1.5" > allocation_color(idx)}> - {sector["sector"]} + {translate_sector(sector["sector"])} {Float.round(sector["percent"] * 1.0, 1)}% diff --git a/lib/pulse_web/live/radar_live.ex b/lib/pulse_web/live/radar_live.ex index 05d59c3..e59485a 100644 --- a/lib/pulse_web/live/radar_live.ex +++ b/lib/pulse_web/live/radar_live.ex @@ -162,6 +162,7 @@ defmodule PulseWeb.RadarLive do