Skip to content

Consume radar.* events: RadarWorker + LiveView + community insights#35

Merged
fleveque merged 8 commits into
mainfrom
feat/share-radar
May 25, 2026
Merged

Consume radar.* events: RadarWorker + LiveView + community insights#35
fleveque merged 8 commits into
mainfrom
feat/share-radar

Conversation

@fleveque

@fleveque fleveque commented May 25, 2026

Copy link
Copy Markdown
Owner

Second of two coordinated PRs. The Quantic side ships the publishing flow + Settings UI (fleveque/dividend-portfolio#171). This is the Pulse mirror: a new GenServer-per-slug RadarWorker tree, a /r/:slug LiveView, and a "Community watchlist" card on the dashboard.

What landed

OTP architecture (clean mirror of the portfolio tree):

  • Pulse.RadarRegistry — separate Registry namespace from PortfolioRegistry
  • Pulse.RadarSupervisor — DynamicSupervisor of RadarWorker children
  • Pulse.RadarWorker — GenServer per slug; recomputes basic metrics on each radar.updated; broadcasts on radar:{slug} (per-page) and radars (aggregator) PubSub
  • Pulse.RadarStore — DETS-backed (priv/data/radars.dets, separate from portfolios.dets); restore_all rehydrates workers on boot
  • Pulse.RadarAggregator — community stats: top-N most-watched stocks, average target prices (suppressed when cohort < MIN_COHORT=3, mirror of Stocks::CommunityTargetPrice on the Quantic side), and a "below community target" derived view sorted by largest gap
  • Pulse.RadarAnalytics — visit-tracking parallel to Pulse.Analytics (separate ETS + DETS tables so portfolio and radar slugs don't conflate counts)

Consumer + Application:

  • @subjects extends with radar.{updated,opted_in,opted_out}. Each handler mirrors the portfolio path.
  • Application registers the new tree + a second restore Task. The two tasks need explicit Supervisor.child_spec ids — without them the default Task id collides and supervisor refuses to boot.

LiveView — /r/:slug (PulseWeb.RadarLive):

  • Same capturable card / branding footer / share-image + share-link button row as PortfolioLive — differentiated by an indigo avatar (eye icon) and a "· Radar" title suffix so screenshots are distinguishable from portfolios.
  • Three summary stat cards (stocks tracked / with target / below target).
  • Dense mini-card grid sorted by largest-below-target first (best opportunities lead). Each card has an info icon (top-right) that toggles a price + target detail overlay — works on tap (mobile) and click (desktop), no title tooltip so touch devices have a real affordance.
  • Bidirectional cross-link: "View radar" on portfolio page when a RadarWorker exists; "View portfolio" on radar page when a PortfolioWorker exists.

Dashboard polish:

  • Restructured into Portfolio stats / Radar stats / How-It-Works sections.
  • New "Community watchlist" card with most-watched stocks + average-target column.
  • "Recently Updated" radars panel sorted by updated_at desc.
  • "Below Community Target" card (always visible, empty state explains the 3-user cohort requirement).

Share images carry a YYYYMMDD-stamped filename (<slug>-radar-20260525.png) and a localized date in the header so screenshots are self-dating.

Localization:

  • New format_date/1 helper using gettext'd month names (full Spanish month names — mayo, not may, to avoid visual collision with English).
  • New translate_sector/1 helper covering the 17 GICS / Yahoo sector names + Unknown. Unknown sectors pass through verbatim.
  • Full Spanish translation pass: filled in all empty msgstr entries and corrected fuzzy ones that had been auto-merged from portfolio strings (e.g. Community WatchlistRadar de la Comunidad, the radar not-found message said cartera).

Deploy this PR before the Quantic PR

NATS here is plain pub/sub (no JetStream queue) — messages with no live subscriber are dropped at the broker. If Quantic ships first, every radar.opted_in and radar.updated from the moment the toggle goes live is lost on the floor until Pulse restarts with the new subjects subscribed.

Shipping this PR first means Pulse subscribes to radar.* immediately and idles harmlessly (no publishers yet, nothing arrives). Then when fleveque/dividend-portfolio#171 deploys, Pulse catches event #1 cleanly.

Test plan

  • mix test — 44 / 44
  • mix format --check-formatted — clean
  • mix compile --warnings-as-errors — clean
  • Manual: with both apps running, share a radar from Quantic (set portfolio_slug + share_radar=true) → RadarWorker spawns; localhost:4000/r/<slug> renders the grid
  • Manual: edit a target price in Quantic → grid updates live via PubSub
  • Manual: with 3+ users sharing the same stock, dashboard "Community watchlist" shows avg target price; if current < avg → appears in "Below Community Target"
  • Manual: restart Pulse — RadarStore.restore_all re-spawns the workers from DETS
  • Manual: save image from radar page → filename is <slug>-radar-<YYYYMMDD>.png with the date visible in the header
  • Manual: switch language to ES → sectors / month names render in Spanish

Rollback

Single revert. The DETS file in priv/data/radars.dets is harmless if left behind (Pulse just ignores radar.* events without the code).

Sister PR

Quantic side (schema + publishing + Settings UI): fleveque/dividend-portfolio#171.

🤖 Generated with Claude Code

fleveque and others added 8 commits May 25, 2026 09:45
Second of two coordinated PRs. The Quantic side (dividend-portfolio
#171) ships the publishing flow + Settings UI. This is the Pulse
mirror: a new GenServer-per-slug RadarWorker tree, a /r/:slug
LiveView, and a "Community watchlist" card on the dashboard.

## OTP architecture (mirrors the portfolio tree exactly)

- `Pulse.RadarRegistry` — Registry (keys: :unique) for slug → pid
  lookup, separate namespace from PortfolioRegistry.
- `Pulse.RadarSupervisor` — DynamicSupervisor managing
  RadarWorker children.
- `Pulse.RadarWorker` — GenServer per shared slug. State is
  stocks + base_currency + computed metrics (stock_count,
  targeted_count, below_target_count). Recomputes on each
  `radar.updated`; broadcasts on `radar:#{slug}` and `radars` PubSub.
- `Pulse.RadarStore` — DETS-backed persistence (priv/data/radars.dets,
  separate from portfolios.dets), with `restore_all` to rehydrate
  workers on app boot.
- `Pulse.RadarAggregator` — community stats GenServer mirroring
  DashboardAggregator: top-N most-watched stocks, average target prices
  (suppressed below MIN_COHORT = 3 to avoid exposing a single user's
  preference), and "below community target" derived view sorted by
  largest gap.

## Consumer + Application

- `Pulse.Nats.Consumer.@subjects` extends with `radar.{updated,
  opted_in,opted_out}`. Each handler mirrors the corresponding
  portfolio handler: opted_in starts the worker (and fills stocks
  if present), opted_out tears it down and clears DETS, updated
  ensures a worker exists then forwards.
- `Pulse.Application` adds the radar registry, supervisor, store,
  aggregator, and a second restore Task. The two restore tasks
  use `Supervisor.child_spec` with explicit IDs to avoid the
  default-id collision.

## Frontend

- New `PulseWeb.RadarLive` at `/r/:slug`. Subscribes to
  `radar:#{slug}` PubSub so target-price edits push without a
  refresh. Renders a stocks table with Symbol / Sector / Price /
  Target / Delta (color-coded) / Yield. Cross-links to `/p/:slug`
  when the same slug also has a PortfolioWorker.
- `PulseWeb.DashboardLive` gets a new "Community watchlist" card
  with the most-watched table + a "below community target"
  sub-section. Subscribes to the existing `dashboard` topic; the
  RadarAggregator broadcasts `{:radar_dashboard_updated, stats}`
  there.
- Router: `live "/r/:slug", RadarLive, :show`.

## Tests

44 total, 0 failures (7 new across RadarWorker + RadarAggregator
covering the worker lifecycle, metric computation, material-change
gating on updated_at, PubSub broadcast, and the aggregator's
MIN_COHORT threshold + below-target derivation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…shboard reorg

Addresses the round-of-feedback on the radar PR:

1. **Bidirectional cross-link**. Portfolio page now shows a "View
   radar" link when a RadarWorker exists for the same slug (mirror of
   the existing "View portfolio" link from RadarLive).

2. **RadarLive visual overhaul**. Same capturable card / branding
   footer / button row as PortfolioLive (Share Image + Share Link
   hooks both wired). Differentiated where it matters: indigo avatar
   with an eye icon vs the portfolio's primary-colored initial, "·
   Radar" suffix on the title, three summary stat cards (stocks
   tracked / with target / below target). Stocks table includes
   per-stock logos like PortfolioLive's grid.

3. **Visit tracking** via new `Pulse.RadarAnalytics` — parallel of
   `Pulse.Analytics` (separate ETS + DETS tables so portfolio and
   radar slugs don't conflate counts). Wired into RadarLive.mount and
   the radar.opted_out NATS handler for cleanup.

4. **Dashboard reorganization**:
   - "Portfolios" section heading + the existing trio (stats cards /
     three-column lists / yield cards / sectors) grouped together
   - New "Radars" section below: a fresh trio of stat cards (Shared
     Radars / Stocks Tracked / Below Community Target) + a three-column
     grid (Community Watchlist / Recently Updated / Trending This
     Week) + a "Below Community Target" strip across the bottom
   - "How It Works" moved to the very end (was previously sandwiched
     between the portfolio and community-watchlist content because of
     a misplaced nested div from the earlier commit)
   - `RadarAggregator.stats` extended with `recent_radars`
     (sorted by updated_at, top-N).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the radar's stocks table with a portfolio-style mini-card
grid (same `capture-grid` columns, same logo placement, same card
structure) so the radar page feels at home next to the portfolio.

Each card surfaces what matters on a radar:
- The stock logo
- The symbol
- A color-coded delta (green when below target, red when above,
  neutral at target, muted when no target set) with a matching dot
- Current price / target price line underneath, monospace

Sort order is what makes the page actionable: most-below-target
first (biggest opportunity → green stripe up top), then at-target,
then above-target, then no-target last. Within each band sorted by
absolute gap so the most striking signal leads.

Dropped the sector column entirely — it's not actionable on a
radar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radars can easily run 50+ stocks (way longer than a portfolio's
typical 5-15 holdings), so the portfolio-equivalent card density was
too tall. Trim:

- Card padding p-4 → p-2
- Logo size: default → 28px
- Symbol text-sm → text-xs
- Status dot w-2.5 → w-1.5
- Delta text-lg → text-xs
- Drop the price/target line entirely from card body; surface it via
  the card's `title` attribute (hover tooltip) so the info is still
  one mouse-over away when wanted.
- Grid: 2/3/4/5 cols → 3/4/6/8 cols at base/sm/md/lg

Net result: ~half the previous card height. A 60-stock radar now
fits in a couple of screens instead of needing a scroll marathon.
Sort order and color scheme unchanged — still most-below-target first
with green/red dots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous version put price + target in the card's `title`
attribute, which is invisible on touch devices and undiscoverable
even on desktop. Replace with:

- A small information-circle icon in the top-right corner of each
  card — visible signal that more is available.
- Tapping/clicking the icon toggles a detail overlay covering the
  card with Price / Target labels and values. Works identically on
  mobile (tap) and desktop (click).
- A close ✕ in the overlay so the dismiss target is obvious. Tapping
  the info icon again also closes it (it's the same `JS.toggle_class`
  binding).

Uses `Phoenix.LiveView.JS.toggle_class`/`add_class` so the whole
interaction is client-side — no server roundtrip per tap.

Symbols may contain dots (e.g. `REP.MC`); we replace `.` with `-` in
the DOM id so the `to: "#..."` selector doesn't trip on CSS escaping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without `.btn` classes the plain <button> elements default to the
arrow cursor, making the info / close icons feel non-interactive on
hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`compute_stats/0` was returning a map without a `:recent_radars`
key, so the dashboard's "Recently Updated" panel rendered empty
even when shared radars existed. DashboardLive accessed the key
via `Map.get(..., :recent_radars, [])` and silently fell back to
the empty list.

Add the missing field: sort all live workers by `updated_at`
desc (nil-safe), take top-N, and emit `%{slug, updated_at,
stock_count, below_target_count}` so the panel can show what's
been touched most recently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible:

1. **Share images** now carry a YYYYMMDD-stamped filename
   (`<slug>-portfolio-20260525.png` / `<slug>-radar-20260525.png`)
   and a localized date in the header so the screenshot itself is
   self-dating. The SaveImage / ShareLink JS hooks read a new
   `data-filename` attribute and append the stamp.

2. **Sector names** are now localized via a `translate_sector/1`
   helper in CoreComponents covering the 17 GICS / Yahoo Finance
   sector names + `Unknown`; unknown names pass through verbatim
   so a new sector never blanks the UI. Wired into the four
   render sites (portfolio sector legend + tooltip, dashboard
   community-sectors legend + tooltip).

3. **Below Community Target** card on the dashboard is now always
   visible — empty state explains the 3-user cohort requirement.
   Previously the whole card was hidden until consensus existed,
   so users never knew the feature was there.

4. **Localized date helper** (`format_date/1`) used by the share
   images. Full Spanish month names (`enero` / `mayo` /
   `diciembre`) — abbreviations like `may` are visually
   indistinguishable from English `May`, so we use full forms.

5. **Spanish translation pass**: filled in all empty `msgstr`
   entries and corrected fuzzy ones that had been auto-merged
   from the portfolio strings (e.g. `Community Watchlist` was
   `Carteras de la comunidad`, the radar not-found message said
   `cartera`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fleveque fleveque merged commit d5d18a2 into main May 25, 2026
1 check passed
@fleveque fleveque deleted the feat/share-radar branch May 25, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant