diff --git a/docs/migration/status.md b/docs/migration/status.md index aa8a432..e8edace 100644 --- a/docs/migration/status.md +++ b/docs/migration/status.md @@ -6,6 +6,8 @@ _Last updated: 2026-06-12_ ## Where we are +**Step 8 slice C landed — Portfolio + Radar parity**: both pages rebuilt around legacy's card-first design. **`yahoo_finance_ex` v0.4 published** (`search/2` — Yahoo's `/v1/finance/search` autocomplete; the 0.3.0 CHANGELOG entry was also backfilled); the `Provider` behaviour gained a `search` callback, implemented by both adapters (AlphaVantage via `SYMBOL_SEARCH`, types normalized); `MarketData.search_stocks/1` ports legacy's merge semantics — local stock rows first (`in_db: true`), provider matches filtered to EQUITY/ETF/MUTUALFUND, deduped, capped at 10, provider errors degrade to DB-only. **Search-first add flow** on both pages (← legacy): debounced async search → results grid → radar adds one-click ("On radar" state), portfolio expands an inline shares/cost form per result. **Card/compact views** with a localStorage-persisted toggle (colocated hook restores on mount); portfolio adds the persisted **sort dropdown** (market value/gain/gain %/symbol); radar sorts opportunity-first (pulse's grid order). **Cards** carry the full legacy payload: score badge (0–10 + Strong/Fair/Weak), metrics grid (PER/EPS/Div/Yield/Payout/MA50/MA200), payment-month grid (shifted months amber), 52-week range bar, ex-div badge (amber ≤3d), status-colored borders + delta badges (radar), gain/loss coloring (portfolio). **Target-price anchors** (← legacy `TargetPriceAnchorsMenu`, flagged by Francesc): one-click suggested buy targets — community average (new `Community.community_targets/0`, same ≥3-cohort privacy rule as the dashboard), 52-week midpoint, MA200, MA50. **Compact rows expand** (flagged by Francesc): a chevron expands the row into the full card inside the table — improvement over legacy's metric-columns toggle, same function. **Sectors now translate** (flagged by Francesc): `translate_sector/1` with a dedicated `sectors` gettext domain (18 provider sector names ×6 locales), applied to every sector bar + the buy-plan badge. Portfolio gains the public-page link + sectors card; ~80 new strings translated ×6 (the translation subagents hit a session limit — translated inline instead). **Deviations**: held stocks show a disabled "In portfolio" in search results instead of legacy's add-more form (the buy-plan checkout owns position merging); legacy's `DividendCalendar` on both pages is deferred to slice D (it's dividend-schedule rendering, same data as the chart work there). Verified by demo-mode screenshots: desktop + mobile (390px), both views, expanded rows. + **Step 8 slice B landed — the homes**: the placeholder hero is gone; `/` is now legacy's dual-mode home. **Anon landing** (← `AnonHome`): aurora hero (CSS blob animation + a colocated-hook word rotator over translated product terms), community marquee strip (pure CSS, hover-pauses, reduced-motion-safe), 3-step explainer, Path-to-Freedom showcase, community + Telegram pitch cards, top-rated leaderboard, the three community stock lists, final CTA. **Dashboard home** (← `DashboardHome`): time-of-day greeting + stat chips (holdings / radar / next ex-div), portfolio stats card (yields + sector bar) or empty-portfolio CTA, mini-widget grid (dividend-income sparklines per currency, freedom progress + ETA, 14-day upcoming ex-divs — max 2 per row, overflow gets its own row), buy-plan teaser (top 3 picks), telegram connect banner (hidden when linked or unconfigured). **Data**: the four community lists (most-held / most-watched / recently-added / top-rated ← legacy `stocks#last_added/most_added/most_held/top_scored`) ride inside `Community.compute_dashboard_stats/0` — the aggregator caches them, so the anon home mounts with **zero provider calls** (legacy needed a 1h Rails cache for this); new context APIs: `Portfolio.stocks_by_holder_count/2`, `Radar.stocks_by_watcher_count/2`, `MarketData.recently_added_stocks/1`, `Portfolio.rate_stock/2` (the legacy dividend score, now public). Lists rank across all non-demo users (legacy semantics: stock-level counts only — identity-free), demo users excluded via `Accounts.demo_user_ids/0`. **Theme aligned to legacy's shadcn neutrals** (after Francesc's visual review): dark base went from blue-tinted slate to legacy's near-black neutral scale (bg `oklch(17.8% 0 0)`), light to pure white, `primary` to shadcn's near-black/near-white button color; the real gradient **logo SVG** ported (one shared `` in the root layout — per-instance defs duplicated DOM ids and broke every LiveView test); the hero went **full-bleed** (negative margins cancel the layout main's padding). Bug found by screenshot comparison: the `dark:` Tailwind variant only matched `[data-theme=dark]`, so **system-dark users got light-variant cards on a dark page** — the custom variant now also matches `prefers-color-scheme: dark` when no explicit theme is set, mirroring when daisyUI's `prefersdark` theme applies. **Deviations confirmed by Francesc (2026-06-12)**: header icon buttons over legacy's text links; top-rated as a grid (not the auto-advancing carousel); community banner text-only for now (preview image revisited in slice F once `/community` is worth screenshotting). Still open for slice F: greeting time-of-day uses UTC server-side (legacy used client-local; needs a TZ hint); rating universe = held∪watched stocks (quote-warm set) instead of legacy's all-stocks scan. **Step 8 slice A landed — chrome parity**: the layout grew the legacy app's frame. App-wide footer (← legacy `Layout.tsx`): the full investment disclaimer (translated ×6 — es verbatim from legacy's own translation), brand mark, stack credit, © + FAQ link; sticky-bottom via a `min-h-screen` flex column (main is `flex-1`). **Mobile nav drawer** (← legacy's right-side Sheet): below `md` the nav collapses into a hamburger → slide-in panel, pure client-side JS commands (no server round-trip), backdrop/Escape/link-click all close it; desktop nav now switches at `md` like legacy. **Active-nav highlighting**: `[data-nav-link]` links get `.nav-active` client-side on every `phx:page-loading-stop` (the nav is static layout HTML, so LiveView patches don't clobber it). Login page rebuilt as the legacy centered card (brand mark, card, OR divider, outline Google button). `` now follows the Gettext locale; title suffix fixed (" · Phoenix Framework" → " · Quantic"). `#portfolio-capture` untouched. @@ -162,7 +164,7 @@ Sliced by **page cluster** (not by component) so each PR is shippable and direct 1. ✅ **A. Chrome** — footer (disclaimer ×7), mobile nav drawer, active-nav state, login card, layout skeleton (above). 2. ✅ **B. The homes** — done (above). -3. **C. Portfolio + Radar** — card/compact view toggle (persisted), sort dropdown, search-first add flow, stats card placement, AI panel polish. +3. ✅ **C. Portfolio + Radar** — done (above). Carried into D: the dividend calendar both pages had in legacy. 4. **D. Dividends + Buy plan** — chart visual treatment (per-currency colors, legends, projected-vs-actual), currency totals cards, table/cart polish. 5. **E. Freedom** — the full visual treatment Step 7 deferred: chart axes/labels/phase colors, insight stat cards, collapsible "other capital" section, progress bar. 6. **F. The comparison gate** — page-by-page pass vs the legacy app (both themes, mobile + desktop), share-image capture verification on `/p`/`/r`, micro-interaction/animation pass. Step 8 is done only when this gate passes. diff --git a/lib/quantic/community.ex b/lib/quantic/community.ex index 5800af6..3d18e05 100644 --- a/lib/quantic/community.ex +++ b/lib/quantic/community.ex @@ -376,6 +376,35 @@ defmodule Quantic.Community do } end + @doc """ + Community average buy targets per symbol (← legacy's target-anchor + service): `%{symbol => %{count: watchers_with_target, average: float | + nil}}` across effectively-shared radars. The average is only published + with a cohort of at least #{@min_cohort} targets (same privacy rule as + the dashboard's `most_watched`); below that, `count` still reports how + many exist so the UI can say "needs N watchers". + """ + @spec community_targets() :: %{ + String.t() => %{count: non_neg_integer(), average: float() | nil} + } + def community_targets do + radar_users = Accounts.list_users_sharing_radar() + + radar_users + |> Enum.map(& &1.id) + |> Radar.targets_for_users() + |> Enum.group_by(& &1.symbol) + |> Map.new(fn {symbol, rows} -> + cohort = + rows + |> Enum.map(& &1.target_buy_price) + |> Enum.reject(&is_nil/1) + |> Enum.map(&Decimal.to_float/1) + + {symbol, %{count: length(cohort), average: cohort_average(cohort)}} + end) + end + @doc """ The zero-state dashboard shape — what `dashboard_stats/0` returns before the aggregator's first compute (or without an aggregator). diff --git a/lib/quantic/market_data.ex b/lib/quantic/market_data.ex index 8b400d4..b6c62a4 100644 --- a/lib/quantic/market_data.ex +++ b/lib/quantic/market_data.ex @@ -226,6 +226,59 @@ defmodule Quantic.MarketData do |> Repo.all() end + # Provider search returns many quote types (currency, future, index, + # crypto, …) that don't fit a dividend-tracking app. Allowlist what we + # want; MUTUALFUND is in because Spanish dividend funds like Baelo are + # mutual funds, not ETFs (← legacy SEARCHABLE_TYPES). + @searchable_types ~w(EQUITY ETF MUTUALFUND) + @search_limit 10 + + @doc """ + Searches for stocks matching a free-text query (← legacy + `stocks#search`): local `stocks` rows first (symbol/name substring, + `in_db: true`), then provider autocomplete matches filtered to + dividend-relevant instrument types, deduped by symbol, capped at + #{@search_limit}. Provider errors degrade to DB-only results — search + never errors to the user. + """ + @spec search_stocks(String.t()) :: [map()] + def search_stocks(query) when is_binary(query) do + case String.trim(query) do + "" -> + [] + + normalized -> + db_matches = db_search(normalized) + db_symbols = MapSet.new(db_matches, & &1.symbol) + + provider_matches = + case provider().search(normalized) do + {:ok, results} -> + results + |> Enum.filter(&(&1.type in @searchable_types)) + |> Enum.reject(&MapSet.member?(db_symbols, &1.symbol)) + |> Enum.map(&Map.put(&1, :in_db, false)) + + {:error, _reason} -> + [] + end + + Enum.take(db_matches ++ provider_matches, @search_limit) + end + end + + defp db_search(query) do + pattern = "%#{String.replace(query, ~r/[%_\\]/, "")}%" + + Stock + |> where([s], ilike(s.symbol, ^pattern) or ilike(s.name, ^pattern)) + |> limit(@search_limit) + |> Repo.all() + |> Enum.map(fn stock -> + %{symbol: stock.symbol, name: stock.name, exchange: nil, type: "EQUITY", in_db: true} + end) + end + @doc """ Inserts a new `Stock` row from a `Quote`, or refreshes the existing one (matched by `symbol`). Updates `name`, `currency`, and `last_synced_at`. diff --git a/lib/quantic/market_data/provider.ex b/lib/quantic/market_data/provider.ex index cee9e99..dc5f046 100644 --- a/lib/quantic/market_data/provider.ex +++ b/lib/quantic/market_data/provider.ex @@ -53,4 +53,21 @@ defmodule Quantic.MarketData.Provider do """ @callback get_dividend_history(symbol :: String.t()) :: {:ok, [%{date: Date.t(), amount: float()}]} | {:error, error()} + + @typedoc "One match returned by `search/1`." + @type search_result :: %{ + symbol: String.t(), + name: String.t(), + exchange: String.t() | nil, + type: String.t() | nil + } + + @doc """ + Searches for tickers matching a free-text query (ticker fragment or + company name), in the provider's relevance order. `type` is the + provider's instrument kind normalized to upper-case (`"EQUITY"`, + `"ETF"`, `"MUTUALFUND"`, …) so callers can filter. Blank queries and + no matches return `{:ok, []}`. + """ + @callback search(query :: String.t()) :: {:ok, [search_result()]} | {:error, error()} end diff --git a/lib/quantic/market_data/providers/alpha_vantage.ex b/lib/quantic/market_data/providers/alpha_vantage.ex index 57c8411..c6025ce 100644 --- a/lib/quantic/market_data/providers/alpha_vantage.ex +++ b/lib/quantic/market_data/providers/alpha_vantage.ex @@ -108,6 +108,48 @@ defmodule Quantic.MarketData.Providers.AlphaVantage do end end + @impl true + def search(query) when is_binary(query) do + case String.trim(query) do + "" -> + {:ok, []} + + normalized -> + with {:ok, body} <- request(function: "SYMBOL_SEARCH", keywords: normalized) do + results = + body + |> Map.get("bestMatches", []) + |> Enum.flat_map(fn match -> + case match["1. symbol"] do + symbol when is_binary(symbol) and symbol != "" -> + [ + %{ + symbol: symbol, + name: present(match["2. name"]) || symbol, + # AV has no exchange field — region is the closest + # display equivalent ("United States", "Frankfurt"). + exchange: present(match["4. region"]), + type: normalize_search_type(match["3. type"]) + } + ] + + _missing -> + [] + end + end) + + {:ok, results} + end + end + end + + # AV says "Equity" / "ETF" / "Mutual Fund"; the behaviour promises + # upper-case space-free kinds matching Yahoo's quoteType vocabulary. + defp normalize_search_type(type) when is_binary(type), + do: type |> String.upcase() |> String.replace(" ", "") + + defp normalize_search_type(_nil), do: nil + ## Mapping defp extract_global_quote(body) do diff --git a/lib/quantic/market_data/providers/yahoo_finance.ex b/lib/quantic/market_data/providers/yahoo_finance.ex index f9b46bf..b04e97f 100644 --- a/lib/quantic/market_data/providers/yahoo_finance.ex +++ b/lib/quantic/market_data/providers/yahoo_finance.ex @@ -47,6 +47,19 @@ defmodule Quantic.MarketData.Providers.YahooFinance do YahooFinanceEx.get_dividend_history(symbol) end + @impl true + def search(query) when is_binary(query) do + case YahooFinanceEx.search(query) do + {:ok, results} -> + # The package passes Yahoo's quoteType through verbatim; the + # behaviour contract promises upper-case kinds. + {:ok, Enum.map(results, &Map.update(&1, :type, nil, fn t -> t && String.upcase(t) end))} + + {:error, _} = err -> + err + end + end + @doc false # Public for tests — pure struct mapping, no HTTP. def to_domain(%YahooFinanceEx.Quote{} = yfe) do diff --git a/lib/quantic/portfolio.ex b/lib/quantic/portfolio.ex index c0301ee..43532b4 100644 --- a/lib/quantic/portfolio.ex +++ b/lib/quantic/portfolio.ex @@ -272,10 +272,19 @@ defmodule Quantic.Portfolio do } ], total_in_preferred: Decimal.t(), + total_cost_in_preferred: Decimal.t(), preferred_currency: String.t(), yoc: Decimal.t() | nil, current_yield: Decimal.t() | nil, - sectors: [%{sector: String.t(), value: Decimal.t(), percent: Decimal.t()}] + sectors: [%{sector: String.t(), value: Decimal.t(), percent: Decimal.t()}], + by_currency: [ + %{ + currency: String.t(), + value: Decimal.t(), + value_in_preferred: Decimal.t(), + fx_rate: Decimal.t() | nil + } + ] } def valuation(%Scope{user: %{preferred_currency: preferred}} = scope) do holdings = list_holdings(scope) @@ -290,16 +299,53 @@ defmodule Quantic.Portfolio do %{ rows: rows, total_in_preferred: total, + # Cost basis of the resolvable rows only (same gate as the total), + # so total − cost is an honest gain/loss over what we could value. + total_cost_in_preferred: total_cost, preferred_currency: preferred, # Forward annual income (quote.dividend × shares) over cost basis / # market value — legacy's yoc and currentYield, in the display # currency. Nil when nothing resolves. yoc: percentage(total_income, total_cost), current_yield: percentage(total_income, total), - sectors: sector_breakdown(rows, total) + sectors: sector_breakdown(rows, total), + # Per-trading-currency subtotals (← legacy's multi-currency totals + # header): each currency's market value in its own units plus the + # FX rate used to fold it into the preferred total. Sorted by + # preferred-currency weight; only resolvable rows contribute. + by_currency: currency_subtotals(rows, preferred) } end + defp currency_subtotals(rows, preferred) do + rows + |> Enum.filter(& &1.value_in_preferred) + |> Enum.group_by(& &1.holding.currency) + |> Enum.map(fn {currency, crows} -> + native = crows |> Enum.map(&native_value/1) |> Enum.reduce(Decimal.new(0), &Decimal.add/2) + + converted = + crows |> Enum.map(& &1.value_in_preferred) |> Enum.reduce(Decimal.new(0), &Decimal.add/2) + + %{ + currency: currency, + value: Decimal.round(native, 2), + value_in_preferred: converted, + fx_rate: + if(currency != preferred and Decimal.gt?(native, 0), + do: converted |> Decimal.div(native) |> Decimal.round(4), + else: nil + ) + } + end) + |> Enum.sort_by(& &1.value_in_preferred, {:desc, Decimal}) + end + + defp native_value(%{holding: %{shares: shares}, quote: %{price: price}}) when is_number(price), + do: Decimal.mult(shares, Decimal.from_float(price * 1.0)) + + defp native_value(_unresolved), do: Decimal.new(0) + # Value-weighted sector breakdown (legacy compute_sectors): groups by # stock.sector ("Unknown" for nil), percent of total market value, # sorted desc. All-or-nothing like legacy: [] when no holding carries diff --git a/lib/quantic_web/components/community_components.ex b/lib/quantic_web/components/community_components.ex index 0687c45..69d7618 100644 --- a/lib/quantic_web/components/community_components.ex +++ b/lib/quantic_web/components/community_components.ex @@ -27,14 +27,14 @@ defmodule QuanticWeb.CommunityComponents do :for={{entry, idx} <- Enum.with_index(@sectors)} class={["h-full", sector_color(idx)]} style={"width: #{entry.percent}%"} - title={"#{entry.sector}: #{entry.percent}%"} + title={"#{translate_sector(entry.sector)}: #{entry.percent}%"} > @@ -45,6 +45,42 @@ defmodule QuanticWeb.CommunityComponents do Enum.at(@sector_colors, rem(index, length(@sector_colors))) end + @doc """ + Translates a Yahoo/AV sector name for display (← legacy + `translateSector`). Sector values arrive as English strings from the + providers and are stored verbatim — display is the only place they + localize. Unknown values pass through untranslated (legacy's + `defaultValue` behavior). Lives in the "sectors" gettext domain. + """ + def translate_sector("Basic Materials"), do: dgettext("sectors", "Basic Materials") + + def translate_sector("Communication Services"), + do: dgettext("sectors", "Communication Services") + + def translate_sector("Consumer Cyclical"), do: dgettext("sectors", "Consumer Cyclical") + def translate_sector("Consumer Defensive"), do: dgettext("sectors", "Consumer Defensive") + + def translate_sector("Consumer Discretionary"), + do: dgettext("sectors", "Consumer Discretionary") + + def translate_sector("Consumer Staples"), do: dgettext("sectors", "Consumer Staples") + def translate_sector("Energy"), do: dgettext("sectors", "Energy") + def translate_sector("Financial Services"), do: dgettext("sectors", "Financial Services") + def translate_sector("Financials"), do: dgettext("sectors", "Financials") + def translate_sector("Health Care"), do: dgettext("sectors", "Health Care") + def translate_sector("Healthcare"), do: dgettext("sectors", "Healthcare") + def translate_sector("Industrials"), do: dgettext("sectors", "Industrials") + + def translate_sector("Information Technology"), + do: dgettext("sectors", "Information Technology") + + def translate_sector("Materials"), do: dgettext("sectors", "Materials") + def translate_sector("Real Estate"), do: dgettext("sectors", "Real Estate") + def translate_sector("Technology"), do: dgettext("sectors", "Technology") + def translate_sector("Utilities"), do: dgettext("sectors", "Utilities") + def translate_sector("Unknown"), do: dgettext("sectors", "Unknown") + def translate_sector(other), do: other + @doc """ The save-image / share-link button pair (← pulse). Both drive JS hooks that capture the `#portfolio-capture` card as a PNG: SaveImage shares diff --git a/lib/quantic_web/components/stock_card_components.ex b/lib/quantic_web/components/stock_card_components.ex new file mode 100644 index 0000000..c0599c7 --- /dev/null +++ b/lib/quantic_web/components/stock_card_components.ex @@ -0,0 +1,1411 @@ +defmodule QuanticWeb.StockCardComponents do + @moduledoc """ + The rich stock display components shared by the Portfolio and Radar + pages (← legacy `RadarStockCard` / `PortfolioStockCard` + their badge + satellites): card views, score/ex-div badges, the payment-month grid, + the 52-week range bar, the card/compact view toggle, and the + search-first add flow. + + Components are data-in only: the LiveViews compute per-row "entries" + (item/holding + quote + rating + derived status) and pass them down. + """ + + use Phoenix.Component + use Gettext, backend: QuanticWeb.Gettext + + import QuanticWeb.CoreComponents, only: [icon: 1, input: 1] + import QuanticWeb.StockComponents, only: [stock_logo: 1] + + alias Phoenix.LiveView.JS + + ## ── View toggle (card / compact) ───────────────────────────────── + + @doc """ + Two-button card/compact toggle (← legacy `ViewToggle`). The choice + persists per browser in localStorage (legacy parity): the colocated + hook restores a stored preference on mount via a `set-view` event + (with `restore: true` so the server skips re-storing), and the server + pushes `store-view-pref` after explicit clicks. + """ + attr :view, :atom, required: true, values: [:card, :compact] + attr :storage_key, :string, required: true, doc: "localStorage key, e.g. \"quantic:radar-view\"" + + def view_toggle(assigns) do + ~H""" +
+ + +
+ + """ + end + + @doc """ + Holdings sort dropdown (← legacy's sort select). Persists per browser + like the view toggle; emits `set-sort`. + """ + attr :sort, :atom, required: true + attr :storage_key, :string, required: true + attr :options, :list, required: true, doc: "[{value_atom, label}]" + + def sort_select(assigns) do + ~H""" +
+ +
+ + """ + end + + ## ── Badges ─────────────────────────────────────────────────────── + + @doc """ + Dividend-score badge (← legacy `ScoreBadge`): `8 · Strong` colored by + band. Renders nothing without a rating. + """ + attr :rating, :map, default: nil, doc: "%{score: 0..10, label: binary} | nil" + + def score_badge(%{rating: nil} = assigns), do: ~H"" + + def score_badge(assigns) do + ~H""" + + {@rating.score} · {score_label(@rating.label)} + + """ + end + + defp score_badge_class("Strong"), do: "badge-success" + defp score_badge_class("Fair"), do: "badge-warning" + defp score_badge_class(_weak), do: "badge-error" + + defp score_label("Strong"), do: gettext("Strong") + defp score_label("Fair"), do: gettext("Fair") + defp score_label("Weak"), do: gettext("Weak") + defp score_label(other), do: other + + @doc """ + Compact "ex-div in Nd" badge (← legacy `ExDividendBadge`). Hidden when + there's no upcoming ex-div inside the window; amber when ≤ 3 days. + """ + attr :ex_date, :any, default: nil + attr :window_days, :integer, default: 14 + + def ex_dividend_badge(assigns) do + days = + case assigns.ex_date do + %Date{} = date -> Date.diff(date, Date.utc_today()) + _none -> nil + end + + assigns = assign(assigns, :days, days) + + ~H""" + = 0 && @days <= @window_days} + class={[ + "badge badge-outline badge-sm gap-1", + if(@days <= 3, + do: "border-amber-500/40 text-amber-700 dark:text-amber-400", + else: "border-blue-500/40 text-blue-700 dark:text-blue-400" + ) + ]} + title={Date.to_string(@ex_date)} + > + <.icon name="hero-calendar" class="size-3" /> + <%= cond do %> + <% @days == 0 -> %> + {gettext("Ex-div today")} + <% @days == 1 -> %> + {gettext("Ex-div tomorrow")} + <% true -> %> + {gettext("Ex-div in %{days}d", days: @days)} + <% end %> + + """ + end + + ## ── Payment-month grid ─────────────────────────────────────────── + + @month_abbreviations ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) + + @doc """ + Twelve squares, one per month (← legacy `DividendMonthGrid`): emerald + for regular payment months, amber for shifted ones. Tooltip lists the + month names. Renders a "no schedule" hint when the schedule is empty. + """ + attr :payment_months, :list, default: [] + attr :shifted_months, :list, default: [] + attr :size, :atom, default: :md, values: [:sm, :md], doc: "sm fits the dense compact columns" + + def month_grid(%{payment_months: []} = assigns) do + ~H""" + {gettext("No schedule data")} + """ + end + + def month_grid(assigns) do + primary = assigns.payment_months -- assigns.shifted_months + + title = + gettext("Pays in %{months}", months: month_names(primary)) <> + if assigns.shifted_months != [], + do: + " · " <> + gettext("sometimes in %{months}", months: month_names(assigns.shifted_months)), + else: "" + + assigns = assign(assigns, primary: primary, title: title) + + ~H""" + + + + + """ + end + + defp month_names(months) do + months + |> Enum.sort() + |> Enum.map(&Enum.at(@month_abbreviations, &1 - 1)) + |> Enum.join(", ") + end + + ## ── 52-week range ──────────────────────────────────────────────── + + @doc """ + Low–high bar with a dot at the current position (← legacy + `FiftyTwoWeekRange`). Hidden without range data. + """ + attr :low, :any, default: nil + attr :high, :any, default: nil + attr :position, :any, default: nil, doc: "0..100 or nil" + + def fifty_two_week_range(assigns) do + ~H""" + + {fmt_num(@low)} + + + + + {fmt_num(@high)} + + """ + end + + ## ── Search-first add flow ──────────────────────────────────────── + + @doc """ + The search box + results grid (← legacy's search forms + + `SearchResultCard`). Emits `search` on debounced input change. Each + result renders the `:action` slot (page-specific add controls) with + the result as its argument. + """ + attr :id, :string, required: true + attr :query, :string, default: "" + attr :results, :any, default: nil, doc: "nil (untouched) | :loading | [result]" + attr :placeholder, :string, required: true + slot :action, required: true + + def stock_search(assigns) do + ~H""" +
+
+ +
+ +
+ {gettext("Searching…")} +
+ +

+ {gettext("No matches. Try the exact ticker symbol.")} +

+ +
+
+
+ <.stock_logo symbol={result.symbol} size={28} /> +
+

+ {result.symbol} + + {result.exchange} + +

+

{result.name}

+
+
+
+ {render_slot(@action, result)} +
+
+
+
+ """ + end + + @doc """ + "Suggest a target" dropdown (← legacy `TargetPriceAnchorsMenu`): + one-click anchor prices — community average (cohort-gated), 52-week + midpoint, MA200, MA50 — applied directly as the buy target. Rows + without a value render disabled with a reason. + """ + attr :item_id, :any, required: true + attr :anchors, :list, required: true, doc: "[%{key, label, value, secondary}]" + + def target_anchors_menu(assigns) do + assigns = + assigns + |> assign(:enabled, Enum.any?(assigns.anchors, & &1.value)) + |> assign(:menu_id, "anchors-#{assigns.item_id}") + + ~H""" +