Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/migration/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<defs>` 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). `<html lang>` now follows the Gettext locale; title suffix fixed (" · Phoenix Framework" → " · Quantic"). `#portfolio-capture` untouched.
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions lib/quantic/community.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
53 changes: 53 additions & 0 deletions lib/quantic/market_data.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
17 changes: 17 additions & 0 deletions lib/quantic/market_data/provider.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 42 additions & 0 deletions lib/quantic/market_data/providers/alpha_vantage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions lib/quantic/market_data/providers/yahoo_finance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions lib/quantic/portfolio.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
40 changes: 38 additions & 2 deletions lib/quantic_web/components/community_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}%"}
>
</div>
</div>
<ul class="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<li :for={{entry, idx} <- Enum.with_index(@sectors)} class="flex items-center gap-1.5">
<span class={["size-2 rounded-sm", sector_color(idx)]}></span>
<span>{entry.sector}</span>
<span>{translate_sector(entry.sector)}</span>
<span class="text-base-content/50 tabular-nums">{entry.percent}%</span>
</li>
</ul>
Expand All @@ -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
Expand Down
Loading
Loading