diff --git a/lib/pulse/dashboard_aggregator.ex b/lib/pulse/dashboard_aggregator.ex index 8f8e57f..3f9ce74 100644 --- a/lib/pulse/dashboard_aggregator.ex +++ b/lib/pulse/dashboard_aggregator.ex @@ -105,12 +105,27 @@ 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 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(10) + |> 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). 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..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, @@ -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) @@ -181,9 +183,9 @@ 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" />

{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)