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
15 changes: 15 additions & 0 deletions lib/pulse/dashboard_aggregator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 11 additions & 2 deletions lib/pulse/portfolio_worker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions lib/pulse/store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 32 additions & 10 deletions lib/pulse_web/live/dashboard_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -181,9 +183,9 @@ defmodule PulseWeb.DashboardLive do
<div class="card-body p-5">
<div class="flex items-center gap-2 mb-4">
<.icon name="hero-briefcase" class="size-5 text-primary" />
<h2 class="text-lg font-bold">{gettext("Latest Portfolios")}</h2>
<h2 class="text-lg font-bold">{gettext("Recently Updated")}</h2>
</div>
<div :if={@portfolio_slugs == []} class="py-8 text-center">
<div :if={@recent_portfolios == []} class="py-8 text-center">
<.icon name="hero-briefcase" class="size-12 mx-auto text-base-content/20 mb-3" />
<p class="text-base-content/50 text-sm">{gettext("No portfolios shared yet")}</p>
<p class="text-base-content/40 text-xs mt-1">
Expand All @@ -192,21 +194,24 @@ defmodule PulseWeb.DashboardLive do
{gettext("settings")}
</p>
</div>
<div :if={@portfolio_slugs != []} class="space-y-2">
<div :if={@recent_portfolios != []} class="space-y-2">
<.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"
>
<span class="flex items-center justify-center size-8 rounded-full bg-primary/15 text-primary text-sm font-bold">
{slug |> String.first() |> String.upcase()}
{p.slug |> String.first() |> String.upcase()}
</span>
<span class="font-medium flex-1 group-hover:text-primary transition-colors">
{slug}
<span class="font-medium flex-1 group-hover:text-primary transition-colors truncate">
{p.slug}
</span>
<span class="text-xs text-base-content/40 font-mono">
{relative_time(p.updated_at)}
</span>
<.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"
/>
</.link>
</div>
Expand Down Expand Up @@ -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
21 changes: 17 additions & 4 deletions test/pulse/store_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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)
Expand Down
Loading