From 38a6c2fff0cb5cab7a56eed55b47895b1184464d Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Thu, 21 May 2026 17:33:43 +0200 Subject: [PATCH 1/4] Sort 'Latest Portfolios' by recency + show relative time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card on / listed slugs in non-deterministic order (DynamicSupervisor child order) which was effectively random — users couldn't tell which portfolios had recent activity. Now sorted by most-recently-updated and labelled with a compact relative timestamp. - PortfolioWorker: new `updated_at` field on the defstruct, stamped on every `:update_holdings` cast and on init (nil) so an idle worker doesn't show up as freshly active. - Store: persists `updated_at` alongside holdings / base_currency / stats, and feeds it back through a `__updated_at` payload override on restore so a server restart doesn't make every portfolio look new. - DashboardAggregator: new `recent_portfolios` derived field — top 5 sorted by `updated_at` desc, excluding workers that haven't received a payload yet. - DashboardLive: template renders the new list with a small `relative_time/1` helper (just now / Nm / Nh / Nd / Nw ago, gettext'd). 33 tests / 0 failures. --- lib/pulse/dashboard_aggregator.ex | 12 +++++++++ lib/pulse/portfolio_worker.ex | 13 ++++++++-- lib/pulse/store.ex | 6 +++-- lib/pulse_web/live/dashboard_live.ex | 38 ++++++++++++++++++++++------ test/pulse/store_test.exs | 21 ++++++++++++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/lib/pulse/dashboard_aggregator.ex b/lib/pulse/dashboard_aggregator.ex index 8f8e57f..89c2ff9 100644 --- a/lib/pulse/dashboard_aggregator.ex +++ b/lib/pulse/dashboard_aggregator.ex @@ -105,12 +105,24 @@ defmodule Pulse.DashboardAggregator do total_value: Float.round(total_value * 1.0, 2), popular_stocks: Enum.take(stock_counts, 10), portfolio_slugs: Enum.map(portfolios, & &1.slug), + recent_portfolios: recent_portfolios(portfolios), community_sectors: community_sectors(portfolios), community_yoc: community_average(portfolios, "yoc"), community_current_yield: community_average(portfolios, "currentYield") } end + # Top 5 portfolios sorted by most-recently-updated. Excludes portfolios that + # haven't received a payload yet (updated_at == nil) so an idle worker + # doesn't clutter the list. + defp recent_portfolios(portfolios) do + portfolios + |> Enum.reject(fn p -> is_nil(p.updated_at) end) + |> Enum.sort_by(& &1.updated_at, {:desc, DateTime}) + |> Enum.take(5) + |> Enum.map(fn p -> %{slug: p.slug, updated_at: p.updated_at} end) + end + # Simple mean across portfolios that ship a stats block — answers "what's the # typical Quantic user's yield?" rather than a value-weighted aggregate. Nil # if no worker has stats yet (newer Rails ships them, older deploys don't). diff --git a/lib/pulse/portfolio_worker.ex b/lib/pulse/portfolio_worker.ex index cd8428d..82708b2 100644 --- a/lib/pulse/portfolio_worker.ex +++ b/lib/pulse/portfolio_worker.ex @@ -22,7 +22,7 @@ defmodule Pulse.PortfolioWorker do require Logger - defstruct [:slug, holdings: [], metrics: %{}, base_currency: "USD", stats: nil] + defstruct [:slug, :updated_at, holdings: [], metrics: %{}, base_currency: "USD", stats: nil] # Client API @@ -47,6 +47,9 @@ defmodule Pulse.PortfolioWorker do @impl true def init(slug) do Logger.info("Starting portfolio worker for #{slug}") + # updated_at stays nil until the first holdings payload arrives — the + # dashboard's "Recent portfolios" list uses presence of this field to + # decide whether the portfolio counts as active. {:ok, %__MODULE__{slug: slug}} end @@ -64,12 +67,18 @@ defmodule Pulse.PortfolioWorker do # LiveView templates already guard for it. stats = payload["stats"] + # On store restore the payload includes the original updated_at so a server + # restart doesn't make every portfolio look freshly active. Otherwise stamp + # with now. + updated_at = payload["__updated_at"] || DateTime.utc_now() + new_state = %{ state | holdings: holdings, base_currency: base_currency, metrics: compute_metrics(holdings), - stats: stats + stats: stats, + updated_at: updated_at } Pulse.Store.put(state.slug, new_state) diff --git a/lib/pulse/store.ex b/lib/pulse/store.ex index 610e65c..06b5a9a 100644 --- a/lib/pulse/store.ex +++ b/lib/pulse/store.ex @@ -30,7 +30,8 @@ defmodule Pulse.Store do persistable = %{ holdings: Map.get(state, :holdings, []), base_currency: Map.get(state, :base_currency, "USD"), - stats: Map.get(state, :stats) + stats: Map.get(state, :stats), + updated_at: Map.get(state, :updated_at) } GenServer.cast(__MODULE__, {:put, slug, persistable}) @@ -145,7 +146,8 @@ defmodule Pulse.Store do payload = %{ "holdings" => state[:holdings] || [], "base_currency" => state[:base_currency] || "USD", - "stats" => state[:stats] + "stats" => state[:stats], + "__updated_at" => state[:updated_at] } Pulse.PortfolioWorker.update_holdings(slug, payload) diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index 9d72cdb..a517c9a 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -26,6 +26,7 @@ defmodule PulseWeb.DashboardLive do show_value: stats.portfolio_count > 5 and stats.total_value > 100_000, popular_stocks: stats.popular_stocks, portfolio_slugs: stats.portfolio_slugs, + recent_portfolios: Map.get(stats, :recent_portfolios, []), community_sectors: Map.get(stats, :community_sectors, []), community_yoc: Map.get(stats, :community_yoc), community_current_yield: Map.get(stats, :community_current_yield), @@ -43,6 +44,7 @@ defmodule PulseWeb.DashboardLive do show_value: stats.portfolio_count > 5 and stats.total_value > 100_000, popular_stocks: stats.popular_stocks, portfolio_slugs: stats.portfolio_slugs, + recent_portfolios: Map.get(stats, :recent_portfolios, []), community_sectors: Map.get(stats, :community_sectors, []), community_yoc: Map.get(stats, :community_yoc), community_current_yield: Map.get(stats, :community_current_yield) @@ -183,7 +185,7 @@ defmodule PulseWeb.DashboardLive do <.icon name="hero-briefcase" class="size-5 text-primary" />

{gettext("Latest Portfolios")}

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

{gettext("No portfolios shared yet")}

@@ -192,21 +194,24 @@ defmodule PulseWeb.DashboardLive do {gettext("settings")}

-
+
<.link - :for={slug <- @portfolio_slugs} - navigate={~p"/p/#{slug}"} + :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" > - {slug |> String.first() |> String.upcase()} + {p.slug |> String.first() |> String.upcase()} - - {slug} + + {p.slug} + + + {relative_time(p.updated_at)} <.icon name="hero-arrow-right-micro" - class="size-4 ml-auto text-base-content/30 group-hover:text-primary transition-colors" + class="size-4 text-base-content/30 group-hover:text-primary transition-colors" />
@@ -425,4 +430,21 @@ defmodule PulseWeb.DashboardLive do defp format_number(value) when is_integer(value), do: "#{value}" defp format_number(_), do: "0" + + # Compact relative-time formatter used by the "Latest Portfolios" list. Server + # renders this once per stats broadcast; precision below the minute isn't + # interesting for human-readable "X ago". + defp relative_time(nil), do: "" + + defp relative_time(%DateTime{} = dt) do + seconds = DateTime.diff(DateTime.utc_now(), dt) + + cond do + seconds < 60 -> gettext("just now") + seconds < 3600 -> gettext("%{count}m ago", count: div(seconds, 60)) + seconds < 86_400 -> gettext("%{count}h ago", count: div(seconds, 3600)) + seconds < 604_800 -> gettext("%{count}d ago", count: div(seconds, 86_400)) + true -> gettext("%{count}w ago", count: div(seconds, 604_800)) + end + end end diff --git a/test/pulse/store_test.exs b/test/pulse/store_test.exs index fd7c8f6..182fe14 100644 --- a/test/pulse/store_test.exs +++ b/test/pulse/store_test.exs @@ -20,7 +20,8 @@ defmodule Pulse.StoreTest do state = %{ holdings: [%{"symbol" => "AAPL", "quantity" => 10, "avg_price" => 150.0}], base_currency: "USD", - stats: %{"yoc" => 4.5, "currentYield" => 3.2} + stats: %{"yoc" => 4.5, "currentYield" => 3.2}, + updated_at: nil } Pulse.Store.put("alice", state) @@ -43,7 +44,8 @@ defmodule Pulse.StoreTest do assert Pulse.Store.get("alice") == %{ holdings: [%{"symbol" => "AAPL"}], base_currency: "EUR", - stats: nil + stats: nil, + updated_at: nil } end @@ -52,8 +54,19 @@ defmodule Pulse.StoreTest do end test "overwrites existing entry" do - state1 = %{holdings: [%{"symbol" => "AAPL"}], base_currency: "USD", stats: nil} - state2 = %{holdings: [%{"symbol" => "MSFT"}], base_currency: "USD", stats: nil} + state1 = %{ + holdings: [%{"symbol" => "AAPL"}], + base_currency: "USD", + stats: nil, + updated_at: nil + } + + state2 = %{ + holdings: [%{"symbol" => "MSFT"}], + base_currency: "USD", + stats: nil, + updated_at: nil + } Pulse.Store.put("bob", state1) Process.sleep(50) From 6db73ff85a1028e84fa8c8c9b952c779170afd33 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Thu, 21 May 2026 17:50:51 +0200 Subject: [PATCH 2/4] Don't filter portfolios with no updated_at from Recent list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original gate (Enum.reject is_nil) wiped the list every time the server restarted: workers restored from pre-`updated_at` DETS rows had no timestamp yet, so the empty-state took over until each portfolio got a fresh Quantic broadcast. Sort by updated_at desc but keep nil-timestamp portfolios at the bottom of the list — they still appear, just without a label ('—' in the template) until their next update. --- lib/pulse/dashboard_aggregator.ex | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/pulse/dashboard_aggregator.ex b/lib/pulse/dashboard_aggregator.ex index 89c2ff9..a69e381 100644 --- a/lib/pulse/dashboard_aggregator.ex +++ b/lib/pulse/dashboard_aggregator.ex @@ -112,17 +112,20 @@ defmodule Pulse.DashboardAggregator do } end - # Top 5 portfolios sorted by most-recently-updated. Excludes portfolios that - # haven't received a payload yet (updated_at == nil) so an idle worker - # doesn't clutter the list. + # Top 5 portfolios sorted by most-recently-updated. Portfolios with no + # timestamp yet (e.g. workers restored from a pre-`updated_at` DETS row that + # haven't seen a Quantic broadcast since deploy) sort to the bottom but still + # appear — otherwise the whole list goes empty right after deploys. defp recent_portfolios(portfolios) do portfolios - |> Enum.reject(fn p -> is_nil(p.updated_at) end) - |> Enum.sort_by(& &1.updated_at, {:desc, DateTime}) + |> Enum.sort_by(&sort_key/1, :desc) |> Enum.take(5) |> Enum.map(fn p -> %{slug: p.slug, updated_at: p.updated_at} end) end + defp sort_key(%{updated_at: %DateTime{} = dt}), do: DateTime.to_unix(dt) + defp sort_key(_), do: 0 + # Simple mean across portfolios that ship a stats block — answers "what's the # typical Quantic user's yield?" rather than a value-weighted aggregate. Nil # if no worker has stats yet (newer Rails ships them, older deploys don't). From f004fe751ed82acdbd2e25396cb7e0624e0c97f5 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Thu, 21 May 2026 17:51:16 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Show=20'=E2=80=94'=20instead=20of=20empty?= =?UTF-8?q?=20for=20portfolios=20with=20no=20updated=5Fat=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pulse_web/live/dashboard_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index a517c9a..5a8595d 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -434,7 +434,7 @@ defmodule PulseWeb.DashboardLive do # Compact relative-time formatter used by the "Latest Portfolios" list. Server # renders this once per stats broadcast; precision below the minute isn't # interesting for human-readable "X ago". - defp relative_time(nil), do: "" + defp relative_time(nil), do: "—" defp relative_time(%DateTime{} = dt) do seconds = DateTime.diff(DateTime.utc_now(), dt) From 224db95dac7d505515869e000b784369fde0ea0c Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Thu, 21 May 2026 17:57:13 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Rename=20'Latest=20Portfolios'=20=E2=86=92?= =?UTF-8?q?=20'Recently=20Updated'=20+=20bump=20all=203=20lists=20to=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card label was misleading once the sort criterion changed: it's ordered by `updated_at` desc, so "Recently Updated" is the honest name. Also bump Popular Stocks / Recently Updated / Trending This Week to a consistent max of 10 entries each (popular was already 10; recent went 5 → 10; trending went 5 → 10). --- lib/pulse/dashboard_aggregator.ex | 4 ++-- lib/pulse_web/live/dashboard_live.ex | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pulse/dashboard_aggregator.ex b/lib/pulse/dashboard_aggregator.ex index a69e381..3f9ce74 100644 --- a/lib/pulse/dashboard_aggregator.ex +++ b/lib/pulse/dashboard_aggregator.ex @@ -112,14 +112,14 @@ defmodule Pulse.DashboardAggregator do } end - # Top 5 portfolios sorted by most-recently-updated. Portfolios with no + # Top 10 portfolios sorted by most-recently-updated. Portfolios with no # timestamp yet (e.g. workers restored from a pre-`updated_at` DETS row that # haven't seen a Quantic broadcast since deploy) sort to the bottom but still # appear — otherwise the whole list goes empty right after deploys. defp recent_portfolios(portfolios) do portfolios |> Enum.sort_by(&sort_key/1, :desc) - |> Enum.take(5) + |> Enum.take(10) |> Enum.map(fn p -> %{slug: p.slug, updated_at: p.updated_at} end) end diff --git a/lib/pulse_web/live/dashboard_live.ex b/lib/pulse_web/live/dashboard_live.ex index 5a8595d..1ea8f32 100644 --- a/lib/pulse_web/live/dashboard_live.ex +++ b/lib/pulse_web/live/dashboard_live.ex @@ -13,7 +13,7 @@ defmodule PulseWeb.DashboardLive do "Real-time community dividend portfolio dashboard. " <> "#{stats.portfolio_count} portfolios tracking #{stats.total_holdings} holdings." - top_visited = Pulse.Analytics.top_visited(5) + top_visited = Pulse.Analytics.top_visited(10) {:ok, assign(socket, @@ -183,7 +183,7 @@ defmodule PulseWeb.DashboardLive do
<.icon name="hero-briefcase" class="size-5 text-primary" /> -

{gettext("Latest Portfolios")}

+

{gettext("Recently Updated")}

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