diff --git a/assets/js/app.js b/assets/js/app.js index 27478c6..17d25e4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,7 +26,15 @@ import {hooks as colocatedHooks} from "phoenix-colocated/pulse" import topbar from "../vendor/topbar" import { domToBlob } from "modern-screenshot" -async function capturePortfolio(target) { +function todayStamp() { + const d = new Date() + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, "0") + const dd = String(d.getDate()).padStart(2, "0") + return `${yyyy}${mm}${dd}` +} + +async function capturePortfolio(target, basename) { target.classList.add("capture-mode") // Wait one frame for layout to settle @@ -35,7 +43,8 @@ async function capturePortfolio(target) { try { const bg = getComputedStyle(target).backgroundColor const blob = await domToBlob(target, { scale: 2, backgroundColor: bg }) - return new File([blob], "portfolio.png", { type: "image/png" }) + const filename = `${basename || "pulse"}-${todayStamp()}.png` + return new File([blob], filename, { type: "image/png" }) } finally { target.classList.remove("capture-mode") } @@ -52,11 +61,12 @@ const SaveImage = { const setLabel = (msg) => { if (label) label.textContent = msg } const target = document.getElementById("portfolio-capture") if (!target) return + const basename = this.el.dataset.filename || "pulse" setLabel(capturingLabel) try { - const file = await capturePortfolio(target) + const file = await capturePortfolio(target, basename) // Try native share with image (works on most mobile browsers) if (navigator.canShare && navigator.canShare({ files: [file] })) { @@ -68,7 +78,7 @@ const SaveImage = { // Desktop fallback: download via anchor click const a = document.createElement("a") a.href = URL.createObjectURL(file) - a.download = "portfolio.png" + a.download = file.name a.click() URL.revokeObjectURL(a.href) setLabel(savedLabel) @@ -95,11 +105,12 @@ const ShareLink = { const copiedLabel = this.el.dataset.labelCopied || "Copied!" const setLabel = (msg) => { if (label) label.textContent = msg } const target = document.getElementById("portfolio-capture") + const basename = this.el.dataset.filename || "pulse" if (target) { setLabel(capturingLabel) try { - const file = await capturePortfolio(target) + const file = await capturePortfolio(target, basename) if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title, text, url }) diff --git a/lib/pulse/application.ex b/lib/pulse/application.ex index 787d23d..4cd869b 100644 --- a/lib/pulse/application.ex +++ b/lib/pulse/application.ex @@ -16,11 +16,20 @@ 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, []}, + {Pulse.RadarAnalytics, []}, + + # 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..6c40388 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,45 @@ 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.RadarAnalytics.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..d070c1d --- /dev/null +++ b/lib/pulse/radar_aggregator.ex @@ -0,0 +1,155 @@ +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) + + # Most-recently-updated radars. Workers without a updated_at (shouldn't + # happen but be defensive) sort to the end via a tuple-shaped key. + recent_radars = + radars + |> Enum.sort_by( + fn r -> + {if(r.updated_at, do: 0, else: 1), r.updated_at} + end, + fn + {0, a}, {0, b} -> DateTime.compare(a, b) == :gt + a, b -> a <= b + end + ) + |> Enum.take(@top_n) + |> Enum.map(fn r -> + %{ + slug: r.slug, + updated_at: r.updated_at, + stock_count: length(r.stocks), + below_target_count: r.metrics[:below_target_count] || 0 + } + end) + + %{ + radar_count: length(radars), + total_targeted: by_symbol |> Map.keys() |> length(), + most_watched: most_watched, + below_community_target: below_community_target, + recent_radars: recent_radars + } + 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_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/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/components/core_components.ex b/lib/pulse_web/components/core_components.ex index 9abc14e..5b49dfd 100644 --- a/lib/pulse_web/components/core_components.ex +++ b/lib/pulse_web/components/core_components.ex @@ -495,4 +495,65 @@ defmodule PulseWeb.CoreComponents do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + + @doc """ + Localized short date for share-image captions. Locale-specific format + string lets translators reorder (e.g. "%{day} %{month} %{year}" in ES); + the month is gettext'd separately so abbreviations translate too. + """ + def format_date(%Date{} = date) do + gettext("%{month} %{day}, %{year}", + month: month_abbr(date.month), + day: date.day, + year: date.year + ) + end + + def format_date(_), do: "" + + defp month_abbr(1), do: gettext("Jan") + defp month_abbr(2), do: gettext("Feb") + defp month_abbr(3), do: gettext("Mar") + defp month_abbr(4), do: gettext("Apr") + defp month_abbr(5), do: gettext("May") + defp month_abbr(6), do: gettext("Jun") + defp month_abbr(7), do: gettext("Jul") + defp month_abbr(8), do: gettext("Aug") + defp month_abbr(9), do: gettext("Sep") + defp month_abbr(10), do: gettext("Oct") + defp month_abbr(11), do: gettext("Nov") + defp month_abbr(12), do: gettext("Dec") + + @doc """ + Localizes a stock sector name. Covers both Yahoo Finance's naming + ("Technology", "Consumer Cyclical") and GICS variants ("Information + Technology", "Consumer Discretionary"), plus the "Unknown" bucket the + aggregator emits for null sectors. Unrecognised names pass through + verbatim so we never blank out a screen on a new sector. + """ + def translate_sector(name) when is_binary(name) do + case name do + "Basic Materials" -> gettext("Basic Materials") + "Communication Services" -> gettext("Communication Services") + "Consumer Cyclical" -> gettext("Consumer Cyclical") + "Consumer Defensive" -> gettext("Consumer Defensive") + "Consumer Discretionary" -> gettext("Consumer Discretionary") + "Consumer Staples" -> gettext("Consumer Staples") + "Energy" -> gettext("Energy") + "Financial Services" -> gettext("Financial Services") + "Financials" -> gettext("Financials") + "Health Care" -> gettext("Health Care") + "Healthcare" -> gettext("Healthcare") + "Industrials" -> gettext("Industrials") + "Information Technology" -> gettext("Information Technology") + "Materials" -> gettext("Materials") + "Real Estate" -> gettext("Real Estate") + "Technology" -> gettext("Technology") + "Utilities" -> gettext("Utilities") + "Unknown" -> gettext("Unknown") + other -> other + end + end + + def translate_sector(_), do: "" end diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index 1ea8f32..d3e25b5 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -8,12 +8,14 @@ defmodule PulseWeb.DashboardLive do end stats = Pulse.DashboardAggregator.get_stats() + radar_stats = Pulse.RadarAggregator.get_stats() description = "Real-time community dividend portfolio dashboard. " <> "#{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, @@ -30,7 +32,13 @@ 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, + total_targeted: radar_stats.total_targeted, + most_watched: radar_stats.most_watched, + below_community_target: radar_stats.below_community_target, + recent_radars: Map.get(radar_stats, :recent_radars, []), + top_visited_radars: top_visited_radars )} end @@ -51,6 +59,20 @@ 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, + total_targeted: radar_stats.total_targeted, + most_watched: radar_stats.most_watched, + below_community_target: radar_stats.below_community_target, + recent_radars: Map.get(radar_stats, :recent_radars, []) + )} + end + @impl true def render(assigns) do ~H""" @@ -75,266 +97,492 @@ defmodule PulseWeb.DashboardLive do

- <%!-- Stats Cards --%> -
-
-
-
-
- <.icon name="hero-user-group" class="size-5 text-emerald-600 dark:text-emerald-400" /> + <%!-- ─── Portfolio group ───────────────────────────────────── --%> +
+

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

+ + <%!-- Stats Cards --%> +
+
+
+
+
+ <.icon name="hero-user-group" class="size-5 text-emerald-600 dark:text-emerald-400" /> +
+
+

+ {gettext("Portfolios")} +

+

{@portfolio_count}

+
-
-

- {gettext("Portfolios")} -

-

{@portfolio_count}

+
+
+ +
+
+
+
+ <.icon name="hero-chart-bar" class="size-5 text-blue-600 dark:text-blue-400" /> +
+
+

+ {gettext("Holdings")} +

+

{@total_holdings}

+
+
+
+
+ +
+
+
+
+ <.icon + name={if @show_value, do: "hero-banknotes", else: "hero-eye-slash"} + class="size-5 text-amber-600 dark:text-amber-400" + /> +
+
+

+ {gettext("Community Value")} +

+

+ {format_currency_no_decimals(@total_value)} +

+

+ {gettext("Available soon")} +

+
-
-
-
-
- <.icon name="hero-chart-bar" class="size-5 text-blue-600 dark:text-blue-400" /> +
+ <%!-- Popular Stocks --%> +
+
+
+ <.icon name="hero-fire" class="size-5 text-orange-500" /> +

{gettext("Popular Stocks")}

-
-

- {gettext("Holdings")} +

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

{gettext("No stocks yet")}

+

+ {gettext("Share your portfolio from")} + quantic.es

-

{@total_holdings}

+
+
+
+ + {idx + 1} + + <.stock_logo symbol={stock.symbol} size={32} /> + {stock.symbol} +
+ + <.icon name="hero-user-group-micro" class="size-3.5" /> + {stock.holders} + + + <.icon name="hero-square-3-stack-3d-micro" class="size-3.5" /> + {format_number(stock.total_quantity)} + +
+
-
-
-
-
-
- <.icon - name={if @show_value, do: "hero-banknotes", else: "hero-eye-slash"} - class="size-5 text-amber-600 dark:text-amber-400" - /> + <%!-- Latest Portfolios --%> +
+
+
+ <.icon name="hero-briefcase" class="size-5 text-primary" /> +

{gettext("Recently Updated")}

-
-

- {gettext("Community Value")} +

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

{gettext("No portfolios shared yet")}

+

+ {gettext("Enable sharing in your")} + quantic.es + {gettext("settings")}

-

- {format_currency_no_decimals(@total_value)} -

-

- {gettext("Available soon")} +

+
+ <.link + :for={p <- @recent_portfolios} + navigate={~p"/p/#{p.slug}"} + class="flex items-center gap-2 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-primary/10 transition-colors group" + > + + {p.slug |> String.first() |> String.upcase()} + + + {p.slug} + + + {relative_time(p.updated_at)} + + <.icon + name="hero-arrow-right-micro" + class="size-4 text-base-content/30 group-hover:text-primary transition-colors" + /> + +
+
+
+ + <%!-- Most Visited This Week --%> +
+
+
+ <.icon name="hero-eye" class="size-5 text-violet-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 portfolio to see it here")}

+
+ <.link + :for={{entry, idx} <- Enum.with_index(@top_visited)} + navigate={~p"/p/#{entry.slug}"} + class="flex items-center gap-3 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-violet-500/10 transition-colors group" + > + + {idx + 1} + + + {entry.slug |> String.first() |> String.upcase()} + + + {entry.slug} + + + <.icon name="hero-eye-micro" class="size-3.5" /> + {entry.visits} + + +
-
-
- <%!-- Popular Stocks --%> -
-
-
- <.icon name="hero-fire" class="size-5 text-orange-500" /> -

{gettext("Popular Stocks")}

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

{gettext("No stocks yet")}

-

- {gettext("Share your portfolio from")} - quantic.es -

+ <%!-- Community yield cards (gated by cohort threshold same as total_value) --%> +
+
+
+
+
+ <.icon + name="hero-trending-up" + class="size-5 text-emerald-600 dark:text-emerald-400" + /> +
+
+

+ {gettext("Average Yield on Cost")} +

+

{@community_yoc}%

+
+
-
-
- - {idx + 1} - - <.stock_logo symbol={stock.symbol} size={32} /> - {stock.symbol} -
- - <.icon name="hero-user-group-micro" class="size-3.5" /> - {stock.holders} - - - <.icon name="hero-square-3-stack-3d-micro" class="size-3.5" /> - {format_number(stock.total_quantity)} - +
+ +
+
+
+
+ <.icon name="hero-currency-dollar" class="size-5 text-cyan-600 dark:text-cyan-400" /> +
+
+

+ {gettext("Average Current Yield")} +

+

{@community_current_yield}%

- <%!-- Latest Portfolios --%> -
+ <%!-- Community Sectors (gated by cohort threshold same as total_value) --%> +
- <.icon name="hero-briefcase" class="size-5 text-primary" /> -

{gettext("Recently Updated")}

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

{gettext("No portfolios shared yet")}

-

- {gettext("Enable sharing in your")} - quantic.es - {gettext("settings")} -

+ <.icon name="hero-chart-pie" class="size-5 text-blue-500" /> +

{gettext("Community Sectors")}

-
- <.link - :for={p <- @recent_portfolios} - navigate={~p"/p/#{p.slug}"} - class="flex items-center gap-2 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-primary/10 transition-colors group" +
+
sector_color(idx)} + style={"width: #{sector.percent}%"} + title={"#{translate_sector(sector.sector)}: #{sector.percent}%"} > - - {p.slug |> String.first() |> String.upcase()} - - - {p.slug} - - - {relative_time(p.updated_at)} - - <.icon - name="hero-arrow-right-micro" - class="size-4 text-base-content/30 group-hover:text-primary transition-colors" - /> - +
+
    +
  • + sector_color(idx)}> + {translate_sector(sector.sector)} + {sector.percent}% +
  • +
+
- <%!-- Most Visited This Week --%> -
-
-
- <.icon name="hero-eye" class="size-5 text-violet-500" /> -

{gettext("Trending This Week")}

+ <%!-- ─── 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-eye" class="size-12 mx-auto text-base-content/20 mb-3" /> -

{gettext("No visits yet")}

-

- {gettext("Visit a portfolio to see it here")} -

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

+ {gettext("Stocks Tracked")} +

+

{@total_targeted}

+
+
-
- <.link - :for={{entry, idx} <- Enum.with_index(@top_visited)} - navigate={~p"/p/#{entry.slug}"} - class="flex items-center gap-3 rounded-lg bg-base-300/50 px-3 py-2.5 hover:bg-violet-500/10 transition-colors group" - > - - {idx + 1} - - - {entry.slug |> String.first() |> String.upcase()} - - - {entry.slug} - - - <.icon name="hero-eye-micro" class="size-3.5" /> - {entry.visits} - - +
+ +
+
+
+
+ <.icon name="hero-arrow-trending-down" class="size-5 text-success" /> +
+
+

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

+

{length(@below_community_target)}

+
+
-
- <%!-- Community yield cards (gated by cohort threshold same as total_value) --%> -
-
-
-
-
- <.icon name="hero-trending-up" class="size-5 text-emerald-600 dark:text-emerald-400" /> + <%!-- 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")}

-
-

- {gettext("Average Yield on Cost")} -

-

{@community_yoc}%

+
+ <.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} +
+
-
-
-
-
-
- <.icon name="hero-currency-dollar" class="size-5 text-cyan-600 dark:text-cyan-400" /> + <%!-- 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")}

-
-

- {gettext("Average Current Yield")} +

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

-

{@community_current_yield}%

+
+
+ <.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} + +
-
- <%!-- Community Sectors (gated by cohort threshold same as total_value) --%> -
-
-
- <.icon name="hero-chart-pie" class="size-5 text-blue-500" /> -

{gettext("Community Sectors")}

-
-
-
sector_color(idx)} - style={"width: #{sector.percent}%"} - title={"#{sector.sector}: #{sector.percent}%"} + <%!-- Below community target — always visible (with empty state) so + users discover the feature even before any consensus exists. --%> +
+
+
+ <.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)}% +

    +
    +
  • +
+ +
+

+ {gettext("No consensus buy signals yet")} +

+

+ {gettext( + "Needs 3+ users targeting the same stock with current price below the average target" + )} +

-
    -
  • - sector_color(idx)}> - {sector.sector} - {sector.percent}% -
  • -
-
+
- <%!-- How It Works --%> -
+ <%!-- ─── How It Works (footer, always last) ───────────────── --%> +

{gettext("How It Works")}

diff --git a/lib/pulse_web/live/portfolio_live.ex b/lib/pulse_web/live/portfolio_live.ex index 9dc897b..537c9fe 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,9 +68,17 @@ 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")} +
@@ -149,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 new file mode 100644 index 0000000..e59485a --- /dev/null +++ b/lib/pulse_web/live/radar_live.ex @@ -0,0 +1,333 @@ +defmodule PulseWeb.RadarLive do + @moduledoc """ + Public radar page at `/r/:slug`. Visually mirrors `PortfolioLive` (same + capturable card, share/save-image hooks, branding footer) with radar- + specific content: a stocks table with current price, the user's target + price, and a color-coded delta. Cross-links to `/p/:slug` when the + same slug also has a PortfolioWorker. + """ + use PulseWeb, :live_view + + alias Phoenix.LiveView.JS + + @impl true + def mount(%{"slug" => slug}, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(Pulse.PubSub, "radar:#{slug}") + Pulse.RadarAnalytics.track_visit(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 + + # Per-stock status: below_target / above_target / at_target / unknown. + defp status_for(price, target) when is_number(price) and is_number(target) do + cond do + price < target -> :below_target + price > target -> :above_target + true -> :at_target + end + end + + defp status_for(_, _), do: :unknown + + # 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" + + # 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 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 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 + + # 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 + ~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")} + +
+ +
+ <%!-- Navigation: matches PortfolioLive ordering exactly. --%> +
+ <.link + :if={@has_portfolio} + navigate={~p"/p/#{@slug}"} + class="btn btn-ghost btn-sm" + > + <.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")} + +
+ + <%!-- 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))} +

+
+

+ {format_date(Date.utc_today())} +

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

+
+
+
+ + <%!-- 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. + + Denser than the portfolio grid because radars can be 50+ + 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"]))} + > + + +
+
+ <.stock_logo symbol={s["symbol"]} size={28} /> +
+

{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"]))} + +
+
+ + <%!-- Detail overlay — tap the icon to toggle, tap close to dismiss. --%> + +
+
+ + <%!-- 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 +
+
+
+
+ """ + 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/priv/gettext/default.pot b/priv/gettext/default.pot index 2e53a54..b816b85 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,7 +11,7 @@ msgid "" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:102 +#: lib/pulse_web/live/portfolio_live.ex:121 #, elixir-autogen, elixir-format msgid "1 holding" msgid_plural "%{count} holdings" @@ -23,201 +23,210 @@ msgstr[1] "" msgid "Actions" msgstr "" -#: lib/pulse_web/components/layouts.ex:127 -#: lib/pulse_web/components/layouts.ex:139 +#: lib/pulse_web/components/layouts.ex:126 +#: lib/pulse_web/components/layouts.ex:138 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:121 +#: lib/pulse_web/live/dashboard_live.ex:158 #, elixir-autogen, elixir-format msgid "Available soon" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:54 +#: lib/pulse_web/live/portfolio_live.ex:64 +#: lib/pulse_web/live/radar_live.ex:147 #, elixir-autogen, elixir-format msgid "Back to dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:261 +#: lib/pulse_web/live/dashboard_live.ex:593 #, elixir-autogen, elixir-format msgid "Build Your Portfolio" msgstr "" -#: lib/pulse_web/components/layouts.ex:81 +#: lib/pulse_web/components/layouts.ex:80 #, elixir-autogen, elixir-format msgid "Built with Elixir and Phoenix LiveView" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:65 -#: lib/pulse_web/live/portfolio_live.ex:80 +#: lib/pulse_web/live/portfolio_live.ex:83 +#: lib/pulse_web/live/portfolio_live.ex:99 +#: lib/pulse_web/live/radar_live.ex:167 +#: lib/pulse_web/live/radar_live.ex:183 #, elixir-autogen, elixir-format msgid "Capturing..." msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:20 +#: lib/pulse_web/live/dashboard_live.ex:22 #, elixir-autogen, elixir-format msgid "Community Dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:115 +#: lib/pulse_web/live/dashboard_live.ex:152 #, elixir-autogen, elixir-format msgid "Community Value" msgstr "" -#: lib/pulse_web/components/layouts.ex:73 +#: lib/pulse_web/components/layouts.ex:72 #, elixir-autogen, elixir-format msgid "Community portfolios" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:81 +#: lib/pulse_web/live/portfolio_live.ex:100 +#: lib/pulse_web/live/radar_live.ex:184 #, elixir-autogen, elixir-format msgid "Copied!" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:88 +#: lib/pulse_web/live/portfolio_live.ex:107 +#: lib/pulse_web/live/radar_live.ex:191 #, elixir-autogen, elixir-format msgid "Dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:280 +#: lib/pulse_web/live/dashboard_live.ex:612 #, elixir-autogen, elixir-format msgid "Enable sharing in settings to go public" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:184 +#: lib/pulse_web/live/dashboard_live.ex:221 #, elixir-autogen, elixir-format msgid "Enable sharing in your" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:67 +#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/radar_live.ex:169 #, elixir-autogen, elixir-format msgid "Failed" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:96 +#: lib/pulse_web/live/dashboard_live.ex:133 #, elixir-autogen, elixir-format msgid "Holdings" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:587 #, elixir-autogen, elixir-format msgid "How It Works" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:178 -#, elixir-autogen, elixir-format -msgid "Latest Portfolios" -msgstr "" - -#: lib/pulse_web/live/dashboard_live.ex:287 +#: lib/pulse_web/live/dashboard_live.ex:619 #, elixir-autogen, elixir-format msgid "Live Updates" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:182 +#: lib/pulse_web/live/dashboard_live.ex:219 #, elixir-autogen, elixir-format msgid "No portfolios shared yet" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:139 +#: lib/pulse_web/live/dashboard_live.ex:176 #, elixir-autogen, elixir-format msgid "No stocks yet" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:219 +#: lib/pulse_web/live/dashboard_live.ex:259 +#: lib/pulse_web/live/dashboard_live.ex:507 #, elixir-autogen, elixir-format msgid "No visits yet" msgstr "" -#: lib/pulse_web/components/layouts.ex:77 +#: lib/pulse_web/components/layouts.ex:76 #, elixir-autogen, elixir-format msgid "Part of" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:135 +#: lib/pulse_web/live/dashboard_live.ex:172 #, elixir-autogen, elixir-format msgid "Popular Stocks" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:289 +#: lib/pulse_web/live/dashboard_live.ex:621 #, elixir-autogen, elixir-format msgid "Portfolio changes stream in real-time" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:47 +#: lib/pulse_web/live/portfolio_live.ex:57 #, elixir-autogen, elixir-format msgid "Portfolio not found" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:80 +#: lib/pulse_web/live/dashboard_live.ex:104 +#: lib/pulse_web/live/dashboard_live.ex:117 #, elixir-autogen, elixir-format msgid "Portfolios" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:66 +#: lib/pulse_web/live/dashboard_live.ex:96 #, elixir-autogen, elixir-format msgid "Real-time community dividend portfolio dashboard" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:64 -#: lib/pulse_web/live/portfolio_live.ex:71 +#: lib/pulse_web/live/portfolio_live.ex:82 +#: lib/pulse_web/live/portfolio_live.ex:89 +#: lib/pulse_web/live/radar_live.ex:166 +#: lib/pulse_web/live/radar_live.ex:173 #, elixir-autogen, elixir-format msgid "Save Image" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:66 +#: lib/pulse_web/live/portfolio_live.ex:84 +#: lib/pulse_web/live/radar_live.ex:168 #, elixir-autogen, elixir-format msgid "Saved!" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:278 +#: lib/pulse_web/live/dashboard_live.ex:610 #, elixir-autogen, elixir-format msgid "Share It" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:79 -#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/portfolio_live.ex:98 +#: lib/pulse_web/live/portfolio_live.ex:104 +#: lib/pulse_web/live/radar_live.ex:182 +#: lib/pulse_web/live/radar_live.ex:188 #, elixir-autogen, elixir-format msgid "Share Link" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:141 +#: lib/pulse_web/live/dashboard_live.ex:178 #, elixir-autogen, elixir-format msgid "Share your portfolio from" msgstr "" -#: lib/pulse_web/components/layouts.ex:134 +#: lib/pulse_web/components/layouts.ex:133 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:49 +#: lib/pulse_web/live/portfolio_live.ex:59 #, elixir-autogen, elixir-format msgid "The portfolio \"%{slug}\" doesn't exist or hasn't been shared yet." msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:158 +#: lib/pulse_web/live/portfolio_live.ex:235 #, elixir-autogen, elixir-format msgid "This portfolio has no holdings yet." msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:263 +#: lib/pulse_web/live/dashboard_live.ex:595 #, elixir-autogen, elixir-format msgid "Track your holdings on" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:503 #, elixir-autogen, elixir-format msgid "Trending This Week" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:221 +#: lib/pulse_web/live/dashboard_live.ex:261 #, elixir-autogen, elixir-format msgid "Visit a portfolio to see it here" msgstr "" -#: lib/pulse_web/components/layouts.ex:122 +#: lib/pulse_web/components/layouts.ex:121 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -227,7 +236,351 @@ msgstr "" msgid "close" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:186 +#: lib/pulse_web/live/dashboard_live.ex:223 #, elixir-autogen, elixir-format msgid "settings" msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:694 +#, elixir-autogen, elixir-format +msgid "%{count}d ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:693 +#, elixir-autogen, elixir-format +msgid "%{count}h ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:692 +#, elixir-autogen, elixir-format +msgid "%{count}m ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:695 +#, elixir-autogen, elixir-format +msgid "%{count}w ago" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:505 +#, elixir-autogen, elixir-format +msgid "%{month} %{day}, %{year}" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:211 +#, elixir-autogen, elixir-format +msgid "1 stock tracked" +msgid_plural "%{count} stocks tracked" +msgstr[0] "" +msgstr[1] "" + +#: lib/pulse_web/components/core_components.ex:517 +#, elixir-autogen, elixir-format +msgid "Apr" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:521 +#, elixir-autogen, elixir-format +msgid "Aug" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:330 +#, elixir-autogen, elixir-format +msgid "Average Current Yield" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:311 +#, elixir-autogen, elixir-format +msgid "Average Yield on Cost" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:418 +#: lib/pulse_web/live/dashboard_live.ex:546 +#, elixir-autogen, elixir-format +msgid "Below Community Target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:237 +#, elixir-autogen, elixir-format +msgid "Below target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:294 +#, elixir-autogen, elixir-format +msgid "Close" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:344 +#, elixir-autogen, elixir-format +msgid "Community Sectors" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:435 +#, elixir-autogen, elixir-format +msgid "Community Watchlist" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:144 +#, elixir-autogen, elixir-format +msgid "Current Yield" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:525 +#, elixir-autogen, elixir-format +msgid "Dec" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:515 +#, elixir-autogen, elixir-format +msgid "Feb" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:514 +#, elixir-autogen, elixir-format +msgid "Jan" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:520 +#, elixir-autogen, elixir-format +msgid "Jul" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:519 +#, elixir-autogen, elixir-format +msgid "Jun" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:516 +#, elixir-autogen, elixir-format +msgid "Mar" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:518 +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:575 +#, elixir-autogen, elixir-format +msgid "Needs 3+ users targeting the same stock with current price below the average target" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:572 +#, elixir-autogen, elixir-format +msgid "No consensus buy signals yet" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:472 +#, elixir-autogen, elixir-format +msgid "No radars shared yet" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:439 +#, elixir-autogen, elixir-format +msgid "No stocks tracked yet" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:524 +#, elixir-autogen, elixir-format +msgid "Nov" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:523 +#, elixir-autogen, elixir-format +msgid "Oct" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:299 +#, elixir-autogen, elixir-format +msgid "Price" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:208 +#, elixir-autogen, elixir-format +msgid "Radar" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:140 +#, elixir-autogen, elixir-format +msgid "Radar not found" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:373 +#, elixir-autogen, elixir-format +msgid "Radars" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:468 +#, elixir-autogen, elixir-format +msgid "Recently Updated" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:158 +#, elixir-autogen, elixir-format +msgid "Sectors" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:522 +#, elixir-autogen, elixir-format +msgid "Sep" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:386 +#, elixir-autogen, elixir-format +msgid "Shared Radars" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:266 +#, elixir-autogen, elixir-format +msgid "Show %{symbol} details" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:402 +#, elixir-autogen, elixir-format +msgid "Stocks Tracked" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:223 +#, elixir-autogen, elixir-format +msgid "Stocks tracked" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:308 +#, elixir-autogen, elixir-format +msgid "Target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:142 +#, elixir-autogen, elixir-format +msgid "The radar \"%{slug}\" doesn't exist or hasn't been shared yet." +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:320 +#, elixir-autogen, elixir-format +msgid "This radar is empty." +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:160 +#, elixir-autogen, elixir-format +msgid "View portfolio" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:76 +#, elixir-autogen, elixir-format +msgid "View radar" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:509 +#, elixir-autogen, elixir-format +msgid "Visit a radar to see it here" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:229 +#, elixir-autogen, elixir-format +msgid "With target" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:138 +#, elixir-autogen, elixir-format +msgid "Yield on Cost" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:548 +#, elixir-autogen, elixir-format +msgid "consensus buy signal" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:691 +#, elixir-autogen, elixir-format +msgid "just now" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:536 +#, elixir-autogen, elixir-format +msgid "Basic Materials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:537 +#, elixir-autogen, elixir-format +msgid "Communication Services" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:538 +#, elixir-autogen, elixir-format +msgid "Consumer Cyclical" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:539 +#, elixir-autogen, elixir-format +msgid "Consumer Defensive" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:540 +#, elixir-autogen, elixir-format +msgid "Consumer Discretionary" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:541 +#, elixir-autogen, elixir-format +msgid "Consumer Staples" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:542 +#, elixir-autogen, elixir-format +msgid "Energy" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:543 +#, elixir-autogen, elixir-format +msgid "Financial Services" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:544 +#, elixir-autogen, elixir-format +msgid "Financials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:545 +#, elixir-autogen, elixir-format +msgid "Health Care" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:546 +#, elixir-autogen, elixir-format +msgid "Healthcare" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:547 +#, elixir-autogen, elixir-format +msgid "Industrials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:548 +#, elixir-autogen, elixir-format +msgid "Information Technology" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:549 +#, elixir-autogen, elixir-format +msgid "Materials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:550 +#, elixir-autogen, elixir-format +msgid "Real Estate" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:551 +#, elixir-autogen, elixir-format +msgid "Technology" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:553 +#, elixir-autogen, elixir-format +msgid "Unknown" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:552 +#, elixir-autogen, elixir-format +msgid "Utilities" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a11eaf2..9a3e031 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,7 +11,7 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/pulse_web/live/portfolio_live.ex:102 +#: lib/pulse_web/live/portfolio_live.ex:121 #, elixir-autogen, elixir-format msgid "1 holding" msgid_plural "%{count} holdings" @@ -23,201 +23,210 @@ msgstr[1] "" msgid "Actions" msgstr "" -#: lib/pulse_web/components/layouts.ex:127 -#: lib/pulse_web/components/layouts.ex:139 +#: lib/pulse_web/components/layouts.ex:126 +#: lib/pulse_web/components/layouts.ex:138 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:121 +#: lib/pulse_web/live/dashboard_live.ex:158 #, elixir-autogen, elixir-format msgid "Available soon" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:54 +#: lib/pulse_web/live/portfolio_live.ex:64 +#: lib/pulse_web/live/radar_live.ex:147 #, elixir-autogen, elixir-format msgid "Back to dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:261 +#: lib/pulse_web/live/dashboard_live.ex:593 #, elixir-autogen, elixir-format msgid "Build Your Portfolio" msgstr "" -#: lib/pulse_web/components/layouts.ex:81 +#: lib/pulse_web/components/layouts.ex:80 #, elixir-autogen, elixir-format msgid "Built with Elixir and Phoenix LiveView" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:65 -#: lib/pulse_web/live/portfolio_live.ex:80 +#: lib/pulse_web/live/portfolio_live.ex:83 +#: lib/pulse_web/live/portfolio_live.ex:99 +#: lib/pulse_web/live/radar_live.ex:167 +#: lib/pulse_web/live/radar_live.ex:183 #, elixir-autogen, elixir-format msgid "Capturing..." msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:20 +#: lib/pulse_web/live/dashboard_live.ex:22 #, elixir-autogen, elixir-format msgid "Community Dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:115 +#: lib/pulse_web/live/dashboard_live.ex:152 #, elixir-autogen, elixir-format msgid "Community Value" msgstr "" -#: lib/pulse_web/components/layouts.ex:73 +#: lib/pulse_web/components/layouts.ex:72 #, elixir-autogen, elixir-format msgid "Community portfolios" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:81 +#: lib/pulse_web/live/portfolio_live.ex:100 +#: lib/pulse_web/live/radar_live.ex:184 #, elixir-autogen, elixir-format msgid "Copied!" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:88 +#: lib/pulse_web/live/portfolio_live.ex:107 +#: lib/pulse_web/live/radar_live.ex:191 #, elixir-autogen, elixir-format msgid "Dashboard" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:280 +#: lib/pulse_web/live/dashboard_live.ex:612 #, elixir-autogen, elixir-format msgid "Enable sharing in settings to go public" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:184 +#: lib/pulse_web/live/dashboard_live.ex:221 #, elixir-autogen, elixir-format msgid "Enable sharing in your" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:67 +#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/radar_live.ex:169 #, elixir-autogen, elixir-format msgid "Failed" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:96 +#: lib/pulse_web/live/dashboard_live.ex:133 #, elixir-autogen, elixir-format msgid "Holdings" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:587 #, elixir-autogen, elixir-format msgid "How It Works" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:178 -#, elixir-autogen, elixir-format -msgid "Latest Portfolios" -msgstr "" - -#: lib/pulse_web/live/dashboard_live.ex:287 +#: lib/pulse_web/live/dashboard_live.ex:619 #, elixir-autogen, elixir-format msgid "Live Updates" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:182 +#: lib/pulse_web/live/dashboard_live.ex:219 #, elixir-autogen, elixir-format msgid "No portfolios shared yet" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:139 +#: lib/pulse_web/live/dashboard_live.ex:176 #, elixir-autogen, elixir-format msgid "No stocks yet" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:219 +#: lib/pulse_web/live/dashboard_live.ex:259 +#: lib/pulse_web/live/dashboard_live.ex:507 #, elixir-autogen, elixir-format msgid "No visits yet" msgstr "" -#: lib/pulse_web/components/layouts.ex:77 +#: lib/pulse_web/components/layouts.ex:76 #, elixir-autogen, elixir-format msgid "Part of" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:135 +#: lib/pulse_web/live/dashboard_live.ex:172 #, elixir-autogen, elixir-format msgid "Popular Stocks" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:289 +#: lib/pulse_web/live/dashboard_live.ex:621 #, elixir-autogen, elixir-format msgid "Portfolio changes stream in real-time" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:47 +#: lib/pulse_web/live/portfolio_live.ex:57 #, elixir-autogen, elixir-format msgid "Portfolio not found" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:80 +#: lib/pulse_web/live/dashboard_live.ex:104 +#: lib/pulse_web/live/dashboard_live.ex:117 #, elixir-autogen, elixir-format msgid "Portfolios" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:66 +#: lib/pulse_web/live/dashboard_live.ex:96 #, elixir-autogen, elixir-format msgid "Real-time community dividend portfolio dashboard" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:64 -#: lib/pulse_web/live/portfolio_live.ex:71 +#: lib/pulse_web/live/portfolio_live.ex:82 +#: lib/pulse_web/live/portfolio_live.ex:89 +#: lib/pulse_web/live/radar_live.ex:166 +#: lib/pulse_web/live/radar_live.ex:173 #, elixir-autogen, elixir-format msgid "Save Image" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:66 +#: lib/pulse_web/live/portfolio_live.ex:84 +#: lib/pulse_web/live/radar_live.ex:168 #, elixir-autogen, elixir-format msgid "Saved!" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:278 +#: lib/pulse_web/live/dashboard_live.ex:610 #, elixir-autogen, elixir-format msgid "Share It" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:79 -#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/portfolio_live.ex:98 +#: lib/pulse_web/live/portfolio_live.ex:104 +#: lib/pulse_web/live/radar_live.ex:182 +#: lib/pulse_web/live/radar_live.ex:188 #, elixir-autogen, elixir-format msgid "Share Link" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:141 +#: lib/pulse_web/live/dashboard_live.ex:178 #, elixir-autogen, elixir-format msgid "Share your portfolio from" msgstr "" -#: lib/pulse_web/components/layouts.ex:134 +#: lib/pulse_web/components/layouts.ex:133 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:49 +#: lib/pulse_web/live/portfolio_live.ex:59 #, elixir-autogen, elixir-format msgid "The portfolio \"%{slug}\" doesn't exist or hasn't been shared yet." msgstr "" -#: lib/pulse_web/live/portfolio_live.ex:158 +#: lib/pulse_web/live/portfolio_live.ex:235 #, elixir-autogen, elixir-format msgid "This portfolio has no holdings yet." msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:263 +#: lib/pulse_web/live/dashboard_live.ex:595 #, elixir-autogen, elixir-format msgid "Track your holdings on" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:503 #, elixir-autogen, elixir-format msgid "Trending This Week" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:221 +#: lib/pulse_web/live/dashboard_live.ex:261 #, elixir-autogen, elixir-format msgid "Visit a portfolio to see it here" msgstr "" -#: lib/pulse_web/components/layouts.ex:122 +#: lib/pulse_web/components/layouts.ex:121 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -227,7 +236,351 @@ msgstr "" msgid "close" msgstr "" -#: lib/pulse_web/live/dashboard_live.ex:186 +#: lib/pulse_web/live/dashboard_live.ex:223 #, elixir-autogen, elixir-format msgid "settings" msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:694 +#, elixir-autogen, elixir-format +msgid "%{count}d ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:693 +#, elixir-autogen, elixir-format +msgid "%{count}h ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:692 +#, elixir-autogen, elixir-format +msgid "%{count}m ago" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:695 +#, elixir-autogen, elixir-format +msgid "%{count}w ago" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:505 +#, elixir-autogen, elixir-format +msgid "%{month} %{day}, %{year}" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:211 +#, elixir-autogen, elixir-format +msgid "1 stock tracked" +msgid_plural "%{count} stocks tracked" +msgstr[0] "" +msgstr[1] "" + +#: lib/pulse_web/components/core_components.ex:517 +#, elixir-autogen, elixir-format +msgid "Apr" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:521 +#, elixir-autogen, elixir-format +msgid "Aug" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:330 +#, elixir-autogen, elixir-format +msgid "Average Current Yield" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:311 +#, elixir-autogen, elixir-format +msgid "Average Yield on Cost" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:418 +#: lib/pulse_web/live/dashboard_live.ex:546 +#, elixir-autogen, elixir-format +msgid "Below Community Target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:237 +#, elixir-autogen, elixir-format +msgid "Below target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:294 +#, elixir-autogen, elixir-format, fuzzy +msgid "Close" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:344 +#, elixir-autogen, elixir-format, fuzzy +msgid "Community Sectors" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:435 +#, elixir-autogen, elixir-format, fuzzy +msgid "Community Watchlist" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:144 +#, elixir-autogen, elixir-format +msgid "Current Yield" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:525 +#, elixir-autogen, elixir-format +msgid "Dec" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:515 +#, elixir-autogen, elixir-format +msgid "Feb" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:514 +#, elixir-autogen, elixir-format +msgid "Jan" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:520 +#, elixir-autogen, elixir-format +msgid "Jul" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:519 +#, elixir-autogen, elixir-format +msgid "Jun" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:516 +#, elixir-autogen, elixir-format +msgid "Mar" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:518 +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:575 +#, elixir-autogen, elixir-format +msgid "Needs 3+ users targeting the same stock with current price below the average target" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:572 +#, elixir-autogen, elixir-format +msgid "No consensus buy signals yet" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:472 +#, elixir-autogen, elixir-format +msgid "No radars shared yet" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:439 +#, elixir-autogen, elixir-format, fuzzy +msgid "No stocks tracked yet" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:524 +#, elixir-autogen, elixir-format +msgid "Nov" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:523 +#, elixir-autogen, elixir-format +msgid "Oct" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:299 +#, elixir-autogen, elixir-format +msgid "Price" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:208 +#, elixir-autogen, elixir-format +msgid "Radar" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:140 +#, elixir-autogen, elixir-format +msgid "Radar not found" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:373 +#, elixir-autogen, elixir-format +msgid "Radars" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:468 +#, elixir-autogen, elixir-format +msgid "Recently Updated" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:158 +#, elixir-autogen, elixir-format +msgid "Sectors" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:522 +#, elixir-autogen, elixir-format +msgid "Sep" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:386 +#, elixir-autogen, elixir-format +msgid "Shared Radars" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:266 +#, elixir-autogen, elixir-format +msgid "Show %{symbol} details" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:402 +#, elixir-autogen, elixir-format +msgid "Stocks Tracked" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:223 +#, elixir-autogen, elixir-format +msgid "Stocks tracked" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:308 +#, elixir-autogen, elixir-format +msgid "Target" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:142 +#, elixir-autogen, elixir-format, fuzzy +msgid "The radar \"%{slug}\" doesn't exist or hasn't been shared yet." +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:320 +#, elixir-autogen, elixir-format +msgid "This radar is empty." +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:160 +#, elixir-autogen, elixir-format +msgid "View portfolio" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:76 +#, elixir-autogen, elixir-format +msgid "View radar" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:509 +#, elixir-autogen, elixir-format, fuzzy +msgid "Visit a radar to see it here" +msgstr "" + +#: lib/pulse_web/live/radar_live.ex:229 +#, elixir-autogen, elixir-format +msgid "With target" +msgstr "" + +#: lib/pulse_web/live/portfolio_live.ex:138 +#, elixir-autogen, elixir-format +msgid "Yield on Cost" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:548 +#, elixir-autogen, elixir-format +msgid "consensus buy signal" +msgstr "" + +#: lib/pulse_web/live/dashboard_live.ex:691 +#, elixir-autogen, elixir-format +msgid "just now" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:536 +#, elixir-autogen, elixir-format +msgid "Basic Materials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:537 +#, elixir-autogen, elixir-format +msgid "Communication Services" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:538 +#, elixir-autogen, elixir-format +msgid "Consumer Cyclical" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:539 +#, elixir-autogen, elixir-format +msgid "Consumer Defensive" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:540 +#, elixir-autogen, elixir-format +msgid "Consumer Discretionary" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:541 +#, elixir-autogen, elixir-format +msgid "Consumer Staples" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:542 +#, elixir-autogen, elixir-format +msgid "Energy" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:543 +#, elixir-autogen, elixir-format +msgid "Financial Services" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:544 +#, elixir-autogen, elixir-format +msgid "Financials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:545 +#, elixir-autogen, elixir-format +msgid "Health Care" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:546 +#, elixir-autogen, elixir-format +msgid "Healthcare" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:547 +#, elixir-autogen, elixir-format +msgid "Industrials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:548 +#, elixir-autogen, elixir-format +msgid "Information Technology" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:549 +#, elixir-autogen, elixir-format +msgid "Materials" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:550 +#, elixir-autogen, elixir-format +msgid "Real Estate" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:551 +#, elixir-autogen, elixir-format +msgid "Technology" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:553 +#, elixir-autogen, elixir-format +msgid "Unknown" +msgstr "" + +#: lib/pulse_web/components/core_components.ex:552 +#, elixir-autogen, elixir-format +msgid "Utilities" +msgstr "" diff --git a/priv/gettext/es/LC_MESSAGES/default.po b/priv/gettext/es/LC_MESSAGES/default.po index 0379d6a..e21056e 100644 --- a/priv/gettext/es/LC_MESSAGES/default.po +++ b/priv/gettext/es/LC_MESSAGES/default.po @@ -11,7 +11,7 @@ msgstr "" "Language: es\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/pulse_web/live/portfolio_live.ex:102 +#: lib/pulse_web/live/portfolio_live.ex:121 #, elixir-autogen, elixir-format msgid "1 holding" msgid_plural "%{count} holdings" @@ -23,201 +23,210 @@ msgstr[1] "%{count} posiciones" msgid "Actions" msgstr "Acciones" -#: lib/pulse_web/components/layouts.ex:127 -#: lib/pulse_web/components/layouts.ex:139 +#: lib/pulse_web/components/layouts.ex:126 +#: lib/pulse_web/components/layouts.ex:138 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Intentando reconectar" -#: lib/pulse_web/live/dashboard_live.ex:121 +#: lib/pulse_web/live/dashboard_live.ex:158 #, elixir-autogen, elixir-format msgid "Available soon" msgstr "Disponible pronto" -#: lib/pulse_web/live/portfolio_live.ex:54 +#: lib/pulse_web/live/portfolio_live.ex:64 +#: lib/pulse_web/live/radar_live.ex:147 #, elixir-autogen, elixir-format msgid "Back to dashboard" msgstr "Volver al panel" -#: lib/pulse_web/live/dashboard_live.ex:261 +#: lib/pulse_web/live/dashboard_live.ex:593 #, elixir-autogen, elixir-format msgid "Build Your Portfolio" msgstr "Construye tu Cartera" -#: lib/pulse_web/components/layouts.ex:81 +#: lib/pulse_web/components/layouts.ex:80 #, elixir-autogen, elixir-format msgid "Built with Elixir and Phoenix LiveView" msgstr "Hecho con Elixir y Phoenix LiveView" -#: lib/pulse_web/live/portfolio_live.ex:65 -#: lib/pulse_web/live/portfolio_live.ex:80 +#: lib/pulse_web/live/portfolio_live.ex:83 +#: lib/pulse_web/live/portfolio_live.ex:99 +#: lib/pulse_web/live/radar_live.ex:167 +#: lib/pulse_web/live/radar_live.ex:183 #, elixir-autogen, elixir-format msgid "Capturing..." msgstr "Capturando..." -#: lib/pulse_web/live/dashboard_live.ex:20 +#: lib/pulse_web/live/dashboard_live.ex:22 #, elixir-autogen, elixir-format msgid "Community Dashboard" msgstr "Panel de la Comunidad" -#: lib/pulse_web/live/dashboard_live.ex:115 +#: lib/pulse_web/live/dashboard_live.ex:152 #, elixir-autogen, elixir-format msgid "Community Value" msgstr "Valor de la Comunidad" -#: lib/pulse_web/components/layouts.ex:73 +#: lib/pulse_web/components/layouts.ex:72 #, elixir-autogen, elixir-format msgid "Community portfolios" msgstr "Carteras de la comunidad" -#: lib/pulse_web/live/portfolio_live.ex:81 +#: lib/pulse_web/live/portfolio_live.ex:100 +#: lib/pulse_web/live/radar_live.ex:184 #, elixir-autogen, elixir-format msgid "Copied!" msgstr "¡Copiado!" -#: lib/pulse_web/live/portfolio_live.ex:88 +#: lib/pulse_web/live/portfolio_live.ex:107 +#: lib/pulse_web/live/radar_live.ex:191 #, elixir-autogen, elixir-format msgid "Dashboard" msgstr "Panel" -#: lib/pulse_web/live/dashboard_live.ex:280 +#: lib/pulse_web/live/dashboard_live.ex:612 #, elixir-autogen, elixir-format msgid "Enable sharing in settings to go public" msgstr "Activa la opción de compartir en ajustes para hacerla pública" -#: lib/pulse_web/live/dashboard_live.ex:184 +#: lib/pulse_web/live/dashboard_live.ex:221 #, elixir-autogen, elixir-format msgid "Enable sharing in your" msgstr "Activa compartir en tu" -#: lib/pulse_web/live/portfolio_live.ex:67 +#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/radar_live.ex:169 #, elixir-autogen, elixir-format msgid "Failed" msgstr "Error" -#: lib/pulse_web/live/dashboard_live.ex:96 +#: lib/pulse_web/live/dashboard_live.ex:133 #, elixir-autogen, elixir-format msgid "Holdings" msgstr "Posiciones" -#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:587 #, elixir-autogen, elixir-format msgid "How It Works" msgstr "Cómo Funciona" -#: lib/pulse_web/live/dashboard_live.ex:178 -#, elixir-autogen, elixir-format -msgid "Latest Portfolios" -msgstr "Últimas Carteras" - -#: lib/pulse_web/live/dashboard_live.ex:287 +#: lib/pulse_web/live/dashboard_live.ex:619 #, elixir-autogen, elixir-format msgid "Live Updates" msgstr "Actualizaciones en Vivo" -#: lib/pulse_web/live/dashboard_live.ex:182 +#: lib/pulse_web/live/dashboard_live.ex:219 #, elixir-autogen, elixir-format msgid "No portfolios shared yet" msgstr "Aún no hay carteras compartidas" -#: lib/pulse_web/live/dashboard_live.ex:139 +#: lib/pulse_web/live/dashboard_live.ex:176 #, elixir-autogen, elixir-format msgid "No stocks yet" msgstr "Aún no hay acciones" -#: lib/pulse_web/live/dashboard_live.ex:219 +#: lib/pulse_web/live/dashboard_live.ex:259 +#: lib/pulse_web/live/dashboard_live.ex:507 #, elixir-autogen, elixir-format msgid "No visits yet" msgstr "Aún no hay visitas" -#: lib/pulse_web/components/layouts.ex:77 +#: lib/pulse_web/components/layouts.ex:76 #, elixir-autogen, elixir-format msgid "Part of" msgstr "Parte de" -#: lib/pulse_web/live/dashboard_live.ex:135 +#: lib/pulse_web/live/dashboard_live.ex:172 #, elixir-autogen, elixir-format msgid "Popular Stocks" msgstr "Acciones Populares" -#: lib/pulse_web/live/dashboard_live.ex:289 +#: lib/pulse_web/live/dashboard_live.ex:621 #, elixir-autogen, elixir-format msgid "Portfolio changes stream in real-time" msgstr "Los cambios en la cartera se transmiten en tiempo real" -#: lib/pulse_web/live/portfolio_live.ex:47 +#: lib/pulse_web/live/portfolio_live.ex:57 #, elixir-autogen, elixir-format msgid "Portfolio not found" msgstr "Cartera no encontrada" -#: lib/pulse_web/live/dashboard_live.ex:80 +#: lib/pulse_web/live/dashboard_live.ex:104 +#: lib/pulse_web/live/dashboard_live.ex:117 #, elixir-autogen, elixir-format msgid "Portfolios" msgstr "Carteras" -#: lib/pulse_web/live/dashboard_live.ex:66 +#: lib/pulse_web/live/dashboard_live.ex:96 #, elixir-autogen, elixir-format msgid "Real-time community dividend portfolio dashboard" msgstr "Panel de carteras de dividendos de la comunidad en tiempo real" -#: lib/pulse_web/live/portfolio_live.ex:64 -#: lib/pulse_web/live/portfolio_live.ex:71 +#: lib/pulse_web/live/portfolio_live.ex:82 +#: lib/pulse_web/live/portfolio_live.ex:89 +#: lib/pulse_web/live/radar_live.ex:166 +#: lib/pulse_web/live/radar_live.ex:173 #, elixir-autogen, elixir-format msgid "Save Image" msgstr "Guardar Imagen" -#: lib/pulse_web/live/portfolio_live.ex:66 +#: lib/pulse_web/live/portfolio_live.ex:84 +#: lib/pulse_web/live/radar_live.ex:168 #, elixir-autogen, elixir-format msgid "Saved!" msgstr "¡Guardado!" -#: lib/pulse_web/live/dashboard_live.ex:278 +#: lib/pulse_web/live/dashboard_live.ex:610 #, elixir-autogen, elixir-format msgid "Share It" msgstr "Compártela" -#: lib/pulse_web/live/portfolio_live.ex:79 -#: lib/pulse_web/live/portfolio_live.ex:85 +#: lib/pulse_web/live/portfolio_live.ex:98 +#: lib/pulse_web/live/portfolio_live.ex:104 +#: lib/pulse_web/live/radar_live.ex:182 +#: lib/pulse_web/live/radar_live.ex:188 #, elixir-autogen, elixir-format msgid "Share Link" msgstr "Compartir Enlace" -#: lib/pulse_web/live/dashboard_live.ex:141 +#: lib/pulse_web/live/dashboard_live.ex:178 #, elixir-autogen, elixir-format msgid "Share your portfolio from" msgstr "Comparte tu cartera desde" -#: lib/pulse_web/components/layouts.ex:134 +#: lib/pulse_web/components/layouts.ex:133 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "¡Algo salió mal!" -#: lib/pulse_web/live/portfolio_live.ex:49 +#: lib/pulse_web/live/portfolio_live.ex:59 #, elixir-autogen, elixir-format msgid "The portfolio \"%{slug}\" doesn't exist or hasn't been shared yet." msgstr "La cartera \"%{slug}\" no existe o aún no ha sido compartida." -#: lib/pulse_web/live/portfolio_live.ex:158 +#: lib/pulse_web/live/portfolio_live.ex:235 #, elixir-autogen, elixir-format msgid "This portfolio has no holdings yet." msgstr "Esta cartera aún no tiene posiciones." -#: lib/pulse_web/live/dashboard_live.ex:263 +#: lib/pulse_web/live/dashboard_live.ex:595 #, elixir-autogen, elixir-format msgid "Track your holdings on" msgstr "Gestiona tus posiciones en" -#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:255 +#: lib/pulse_web/live/dashboard_live.ex:503 #, elixir-autogen, elixir-format msgid "Trending This Week" msgstr "Tendencias de la Semana" -#: lib/pulse_web/live/dashboard_live.ex:221 +#: lib/pulse_web/live/dashboard_live.ex:261 #, elixir-autogen, elixir-format msgid "Visit a portfolio to see it here" msgstr "Visita una cartera para verla aquí" -#: lib/pulse_web/components/layouts.ex:122 +#: lib/pulse_web/components/layouts.ex:121 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "No podemos encontrar la conexión" @@ -227,7 +236,351 @@ msgstr "No podemos encontrar la conexión" msgid "close" msgstr "cerrar" -#: lib/pulse_web/live/dashboard_live.ex:186 +#: lib/pulse_web/live/dashboard_live.ex:223 #, elixir-autogen, elixir-format msgid "settings" msgstr "ajustes" + +#: lib/pulse_web/live/dashboard_live.ex:694 +#, elixir-autogen, elixir-format +msgid "%{count}d ago" +msgstr "hace %{count}d" + +#: lib/pulse_web/live/dashboard_live.ex:693 +#, elixir-autogen, elixir-format +msgid "%{count}h ago" +msgstr "hace %{count}h" + +#: lib/pulse_web/live/dashboard_live.ex:692 +#, elixir-autogen, elixir-format +msgid "%{count}m ago" +msgstr "hace %{count}m" + +#: lib/pulse_web/live/dashboard_live.ex:695 +#, elixir-autogen, elixir-format +msgid "%{count}w ago" +msgstr "hace %{count}sem" + +#: lib/pulse_web/components/core_components.ex:505 +#, elixir-autogen, elixir-format +msgid "%{month} %{day}, %{year}" +msgstr "%{day} %{month} %{year}" + +#: lib/pulse_web/live/radar_live.ex:211 +#, elixir-autogen, elixir-format +msgid "1 stock tracked" +msgid_plural "%{count} stocks tracked" +msgstr[0] "1 acción seguida" +msgstr[1] "%{count} acciones seguidas" + +#: lib/pulse_web/components/core_components.ex:517 +#, elixir-autogen, elixir-format +msgid "Apr" +msgstr "abril" + +#: lib/pulse_web/components/core_components.ex:521 +#, elixir-autogen, elixir-format +msgid "Aug" +msgstr "agosto" + +#: lib/pulse_web/live/dashboard_live.ex:330 +#, elixir-autogen, elixir-format +msgid "Average Current Yield" +msgstr "Yield Actual Promedio" + +#: lib/pulse_web/live/dashboard_live.ex:311 +#, elixir-autogen, elixir-format +msgid "Average Yield on Cost" +msgstr "Yield on Cost Promedio" + +#: lib/pulse_web/live/dashboard_live.ex:418 +#: lib/pulse_web/live/dashboard_live.ex:546 +#, elixir-autogen, elixir-format +msgid "Below Community Target" +msgstr "Por Debajo del Objetivo de la Comunidad" + +#: lib/pulse_web/live/radar_live.ex:237 +#, elixir-autogen, elixir-format +msgid "Below target" +msgstr "Por debajo del objetivo" + +#: lib/pulse_web/live/radar_live.ex:294 +#, elixir-autogen, elixir-format +msgid "Close" +msgstr "Cerrar" + +#: lib/pulse_web/live/dashboard_live.ex:344 +#, elixir-autogen, elixir-format +msgid "Community Sectors" +msgstr "Sectores de la Comunidad" + +#: lib/pulse_web/live/dashboard_live.ex:435 +#, elixir-autogen, elixir-format +msgid "Community Watchlist" +msgstr "Radar de la Comunidad" + +#: lib/pulse_web/live/portfolio_live.ex:144 +#, elixir-autogen, elixir-format +msgid "Current Yield" +msgstr "Yield Actual" + +#: lib/pulse_web/components/core_components.ex:525 +#, elixir-autogen, elixir-format +msgid "Dec" +msgstr "diciembre" + +#: lib/pulse_web/components/core_components.ex:515 +#, elixir-autogen, elixir-format +msgid "Feb" +msgstr "febrero" + +#: lib/pulse_web/components/core_components.ex:514 +#, elixir-autogen, elixir-format +msgid "Jan" +msgstr "enero" + +#: lib/pulse_web/components/core_components.ex:520 +#, elixir-autogen, elixir-format +msgid "Jul" +msgstr "julio" + +#: lib/pulse_web/components/core_components.ex:519 +#, elixir-autogen, elixir-format +msgid "Jun" +msgstr "junio" + +#: lib/pulse_web/components/core_components.ex:516 +#, elixir-autogen, elixir-format +msgid "Mar" +msgstr "marzo" + +#: lib/pulse_web/components/core_components.ex:518 +#, elixir-autogen, elixir-format +msgid "May" +msgstr "mayo" + +#: lib/pulse_web/live/dashboard_live.ex:575 +#, elixir-autogen, elixir-format +msgid "Needs 3+ users targeting the same stock with current price below the average target" +msgstr "Requiere 3 o más usuarios siguiendo la misma acción con el precio actual por debajo del objetivo promedio" + +#: lib/pulse_web/live/dashboard_live.ex:572 +#, elixir-autogen, elixir-format +msgid "No consensus buy signals yet" +msgstr "Aún no hay señales de compra de consenso" + +#: lib/pulse_web/live/dashboard_live.ex:472 +#, elixir-autogen, elixir-format +msgid "No radars shared yet" +msgstr "Aún no hay radares compartidos" + +#: lib/pulse_web/live/dashboard_live.ex:439 +#, elixir-autogen, elixir-format +msgid "No stocks tracked yet" +msgstr "Aún no hay acciones seguidas" + +#: lib/pulse_web/components/core_components.ex:524 +#, elixir-autogen, elixir-format +msgid "Nov" +msgstr "noviembre" + +#: lib/pulse_web/components/core_components.ex:523 +#, elixir-autogen, elixir-format +msgid "Oct" +msgstr "octubre" + +#: lib/pulse_web/live/radar_live.ex:299 +#, elixir-autogen, elixir-format +msgid "Price" +msgstr "Precio" + +#: lib/pulse_web/live/radar_live.ex:208 +#, elixir-autogen, elixir-format +msgid "Radar" +msgstr "Radar" + +#: lib/pulse_web/live/radar_live.ex:140 +#, elixir-autogen, elixir-format +msgid "Radar not found" +msgstr "Radar no encontrado" + +#: lib/pulse_web/live/dashboard_live.ex:373 +#, elixir-autogen, elixir-format +msgid "Radars" +msgstr "Radares" + +#: lib/pulse_web/live/dashboard_live.ex:215 +#: lib/pulse_web/live/dashboard_live.ex:468 +#, elixir-autogen, elixir-format +msgid "Recently Updated" +msgstr "Actualizados Recientemente" + +#: lib/pulse_web/live/portfolio_live.ex:158 +#, elixir-autogen, elixir-format +msgid "Sectors" +msgstr "Sectores" + +#: lib/pulse_web/components/core_components.ex:522 +#, elixir-autogen, elixir-format +msgid "Sep" +msgstr "septiembre" + +#: lib/pulse_web/live/dashboard_live.ex:386 +#, elixir-autogen, elixir-format +msgid "Shared Radars" +msgstr "Radares Compartidos" + +#: lib/pulse_web/live/radar_live.ex:266 +#, elixir-autogen, elixir-format +msgid "Show %{symbol} details" +msgstr "Mostrar detalles de %{symbol}" + +#: lib/pulse_web/live/dashboard_live.ex:402 +#, elixir-autogen, elixir-format +msgid "Stocks Tracked" +msgstr "Acciones Seguidas" + +#: lib/pulse_web/live/radar_live.ex:223 +#, elixir-autogen, elixir-format +msgid "Stocks tracked" +msgstr "Acciones seguidas" + +#: lib/pulse_web/live/radar_live.ex:308 +#, elixir-autogen, elixir-format +msgid "Target" +msgstr "Objetivo" + +#: lib/pulse_web/live/radar_live.ex:142 +#, elixir-autogen, elixir-format +msgid "The radar \"%{slug}\" doesn't exist or hasn't been shared yet." +msgstr "El radar \"%{slug}\" no existe o aún no ha sido compartido." + +#: lib/pulse_web/live/radar_live.ex:320 +#, elixir-autogen, elixir-format +msgid "This radar is empty." +msgstr "Este radar está vacío." + +#: lib/pulse_web/live/radar_live.ex:160 +#, elixir-autogen, elixir-format +msgid "View portfolio" +msgstr "Ver cartera" + +#: lib/pulse_web/live/portfolio_live.ex:76 +#, elixir-autogen, elixir-format +msgid "View radar" +msgstr "Ver radar" + +#: lib/pulse_web/live/dashboard_live.ex:509 +#, elixir-autogen, elixir-format +msgid "Visit a radar to see it here" +msgstr "Visita un radar para verlo aquí" + +#: lib/pulse_web/live/radar_live.ex:229 +#, elixir-autogen, elixir-format +msgid "With target" +msgstr "Con objetivo" + +#: lib/pulse_web/live/portfolio_live.ex:138 +#, elixir-autogen, elixir-format +msgid "Yield on Cost" +msgstr "Yield on Cost" + +#: lib/pulse_web/live/dashboard_live.ex:548 +#, elixir-autogen, elixir-format +msgid "consensus buy signal" +msgstr "señal de compra de consenso" + +#: lib/pulse_web/live/dashboard_live.ex:691 +#, elixir-autogen, elixir-format +msgid "just now" +msgstr "ahora mismo" + +#: lib/pulse_web/components/core_components.ex:536 +#, elixir-autogen, elixir-format +msgid "Basic Materials" +msgstr "Materiales Básicos" + +#: lib/pulse_web/components/core_components.ex:537 +#, elixir-autogen, elixir-format +msgid "Communication Services" +msgstr "Servicios de Comunicación" + +#: lib/pulse_web/components/core_components.ex:538 +#, elixir-autogen, elixir-format +msgid "Consumer Cyclical" +msgstr "Consumo Cíclico" + +#: lib/pulse_web/components/core_components.ex:539 +#, elixir-autogen, elixir-format +msgid "Consumer Defensive" +msgstr "Consumo Defensivo" + +#: lib/pulse_web/components/core_components.ex:540 +#, elixir-autogen, elixir-format +msgid "Consumer Discretionary" +msgstr "Consumo Discrecional" + +#: lib/pulse_web/components/core_components.ex:541 +#, elixir-autogen, elixir-format +msgid "Consumer Staples" +msgstr "Consumo Básico" + +#: lib/pulse_web/components/core_components.ex:542 +#, elixir-autogen, elixir-format +msgid "Energy" +msgstr "Energía" + +#: lib/pulse_web/components/core_components.ex:543 +#, elixir-autogen, elixir-format +msgid "Financial Services" +msgstr "Servicios Financieros" + +#: lib/pulse_web/components/core_components.ex:544 +#, elixir-autogen, elixir-format +msgid "Financials" +msgstr "Finanzas" + +#: lib/pulse_web/components/core_components.ex:545 +#, elixir-autogen, elixir-format +msgid "Health Care" +msgstr "Salud" + +#: lib/pulse_web/components/core_components.ex:546 +#, elixir-autogen, elixir-format +msgid "Healthcare" +msgstr "Salud" + +#: lib/pulse_web/components/core_components.ex:547 +#, elixir-autogen, elixir-format +msgid "Industrials" +msgstr "Industria" + +#: lib/pulse_web/components/core_components.ex:548 +#, elixir-autogen, elixir-format +msgid "Information Technology" +msgstr "Tecnología de la Información" + +#: lib/pulse_web/components/core_components.ex:549 +#, elixir-autogen, elixir-format +msgid "Materials" +msgstr "Materiales" + +#: lib/pulse_web/components/core_components.ex:550 +#, elixir-autogen, elixir-format +msgid "Real Estate" +msgstr "Inmobiliario" + +#: lib/pulse_web/components/core_components.ex:551 +#, elixir-autogen, elixir-format +msgid "Technology" +msgstr "Tecnología" + +#: lib/pulse_web/components/core_components.ex:553 +#, elixir-autogen, elixir-format +msgid "Unknown" +msgstr "Desconocido" + +#: lib/pulse_web/components/core_components.ex:552 +#, elixir-autogen, elixir-format +msgid "Utilities" +msgstr "Servicios Públicos" 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