From 8b56f3f828ab953bfc52e2b4da1c4327d4192f71 Mon Sep 17 00:00:00 2001 From: daveminer Date: Wed, 3 Apr 2024 15:40:53 -0400 Subject: [PATCH 1/3] client side row updating --- assets/js/_hooks/CellValueStore.js | 99 ++++++++-------- assets/js/_hooks/TickerHandler.js | 112 ++++++++++++++++--- lib/basket/http/alpaca/bars.ex | 1 + lib/basket_web/components/core_components.ex | 7 +- lib/basket_web/live/overview.ex | 78 ++++++------- 5 files changed, 193 insertions(+), 104 deletions(-) diff --git a/assets/js/_hooks/CellValueStore.js b/assets/js/_hooks/CellValueStore.js index 01af208..d6c09ba 100644 --- a/assets/js/_hooks/CellValueStore.js +++ b/assets/js/_hooks/CellValueStore.js @@ -1,58 +1,65 @@ export const CellValueStore = { mounted() { - this.prevValues = {}; // Initialize an empty object to store previous values - this.updatePrevValues(); // Set initial previous values on mount - - this.handleEvent("run-updated", () => { - // Your updated() function code here - this.flashUpdatedCells(); // Flash cells with updated values - this.updatePrevValues(); // Update previous values after flashing - }); - }, - // updated() { - // this.flashUpdatedCells(); // Flash cells with updated values - // this.updatePrevValues(); // Update previous values after flashing - // }, - updatePrevValues() { - // Loop through each cell and update the store with its current value - this.el.querySelectorAll('td[data-key]').forEach((cell) => { - const key = cell.getAttribute('data-key'); // Assume each cell has a unique data-key attribute - this.prevValues[key] = cell.textContent || cell.innerText; + this.handleEvent("ticker-update-received", (values) => { + console.log(values, "VALUES") + const id = values["S"]; + // select the table row with id equal to id + console.log(id, "ID") + const row = document.querySelector(`tr[id="${id}"]`); + console.log(row, "ROW") + this.highlightChanges(row, values); }); }, - flashUpdatedCells() { - let selector = this.el.querySelectorAll('td[data-key]') - console.log(selector, "SELECTOR") - this.el.querySelectorAll('td[data-key]').forEach((cell) => { - const key = cell.getAttribute('data-key'); - console.log(key, "KEY") - const currentValue = cell.textContent || cell.innerText; - const prevValue = this.prevValues[key]; - if (!prevValue || !isFinite(Number(currentValue))) return; // Skip if no previous value or not a number + highlightChanges(row, values) { + const cells = row.querySelectorAll('td[data-key]') + // Loop through each cell in the row and update the text content + cells.forEach((cell) => { + const dataKey = cell.getAttribute('data-key'); + const [_ticker, key] = dataKey.split('_'); - const prevNum = parseFloat(prevValue); - const currentNum = parseFloat(currentValue); + let value; + switch (key) { + case 'open': + value = values['o']; + break; + case 'high': + value = values['h']; + break; + case 'low': + value = values['l']; + break; + case 'close': + value = values['c']; + break; + case 'volume': + value = values['v']; + break; + default: + break; + } - if (isFinite(prevNum) && isFinite(currentNum)) { - if (prevNum < currentNum) { - this.applyFlash(cell, 'increase'); - } else if (prevNum > currentNum) { - this.applyFlash(cell, 'decrease'); + if (isFinite(value)) { + // Multiple content slots + const content = cell.querySelectorAll('[id$=content-slot]') + const oldValue = content[0].textContent; + content[0].textContent = value; + if (oldValue && isFinite(oldValue)) { + const oldNum = parseFloat(oldValue); + const newNum = parseFloat(value); + if (oldNum < newNum) { + cell.classList.add('bg-emerald-300'); + setTimeout(() => { + cell.classList.remove('bg-emerald-300'); + }, 3000); + } else if (oldNum > newNum) { + cell.classList.add('bg-rose-300'); + setTimeout(() => { + cell.classList.remove('bg-rose-300'); + }, 3000); + } } } }); - }, - applyFlash(cell, changeType) { - console.log("APPLYING FLASH") - // Apply classes based on whether the value increased or decreased - const flashClass = changeType === 'increase' ? 'bg-emerald-300' : 'bg-rose-300'; - cell.classList.add(flashClass); - - setTimeout(() => { - console.log("TIMEOUT RUN") - cell.classList.remove(flashClass) - }, 3000 - ); } }; \ No newline at end of file diff --git a/assets/js/_hooks/TickerHandler.js b/assets/js/_hooks/TickerHandler.js index e36d7b2..4a62048 100644 --- a/assets/js/_hooks/TickerHandler.js +++ b/assets/js/_hooks/TickerHandler.js @@ -1,16 +1,15 @@ export const TickerHandler = { mounted() { this.handleEvent("ticker-removed", ({ ticker }) => { - console.log(ticker, "TICKERREMOVE") let element = document.getElementById(ticker); if (element) element.remove(); }) - this.handleEvent("ticker-added", ({ ticker }) => { - console.log(ticker, "TICKERADD") + this.handleEvent("ticker-added", ({ bars }) => { let table = document.getElementById("ticker-table"); if (table) { let rows = Array.from(table.rows); + rows.push(createRow(bars)) // Sort the rows by id rows.sort((a, b) => a.id.localeCompare(b.id)); @@ -27,19 +26,96 @@ export const TickerHandler = { } }) } - // updated() { - // let table = document.getElementById("ticker-table"); - // console.log(table, "TABLE") - // if (table) { - // let rows = Array.from(table.rows); - // console.log(rows, "ROWS") - - // // Sort the rows by id - // rows.sort((a, b) => a.id.localeCompare(b.id)); - - // // Organize the rows in the table - // rows.forEach(row => table.appendChild(row)); - // console.log(rows, "ROWS2") - // } +} + +function createRow(bars) { + console.log(bars, "BARS") + let row = document.createElement("tr"); + // add an id to the row + row.id = bars.ticker; + + // add classes to the row + row.classList.add("group"); + row.classList.add("hover:bg-zinc-50"); + row.classList.add("text-sm"); + row.classList.add("text-zinc-700"); + + let tickerTd = createCell(bars) + row.appendChild(tickerTd); + + const labelledValues = buildLabels(bars); + + for (idx in labelledValues) { + //console.log(td, "TD") + //let td = createCell(cell, bars.ticker) + row.appendChild(labelledValues[idx]); + } + + let deleteTd = createCell('x') + row.appendChild(deleteTd) + // for (cell in bars) { + // let td = createCell(cell) + // row.appendChild(td); // } -} \ No newline at end of file + + return row +} + +function buildLabels(bars) { + const labels = [ + { label: "Open", value: bars.open }, + { label: "High", value: bars.high }, + { label: "Low", value: bars.low }, + { label: "Close", value: bars.close }, + { label: "Volume", value: bars.volume }, + { label: "Timestamp", value: bars.timestamp } + ] + + let cells = []; + for (idx in labels) { + const { label, value } = labels[idx]; + let td = createCell(label, bars.ticker, value) + cells.push(td); + } + + return cells; +} + +function createCell(label, ticker, value) { + let td = document.createElement("td"); + td.classList.add("relative"); + td.classList.add("p-0"); + td.classList.add("text-center"); + td.classList.add("hover:cursor-pointer"); + + // add a data-key attribute to the cell + td.setAttribute("data-key", `${ticker}_${label}`); + + // add a div to the cell inner + let div = document.createElement("div"); + div.classList.add("block"); + div.classList.add("py-4"); + div.classList.add("pr-6"); + + let span = document.createElement("span"); + span.classList.add("absolute"); + span.classList.add("-inset-y-px"); + span.classList.add("right-0"); + span.classList.add("-left-4"); + span.classList.add("group-hover:bg-zinc-50"); + span.classList.add("sm:rounded-l-xl"); + + let contentSpan = document.createElement("span"); + contentSpan.classList.add(`${ticker}_${label}-content-slot`) + contentSpan.classList.add("relative"); + contentSpan.classList.add("font-semibold"); + contentSpan.classList.add("text-zinc-900"); + contentSpan.innerText = value; + + div.appendChild(span); + div.appendChild(contentSpan); + + td.appendChild(div); + + return td; +} diff --git a/lib/basket/http/alpaca/bars.ex b/lib/basket/http/alpaca/bars.ex index eefcdf5..5b257e1 100644 --- a/lib/basket/http/alpaca/bars.ex +++ b/lib/basket/http/alpaca/bars.ex @@ -3,6 +3,7 @@ defmodule Basket.Http.Alpaca.Bars do Typed instance of a stock chart bars. """ + @derive Jason.Encoder defstruct [:id, :ticker, :close, :open, :high, :low, :volume, :timestamp, :count, :vwap] @type t :: %__MODULE__{ diff --git a/lib/basket_web/components/core_components.ex b/lib/basket_web/components/core_components.ex index 200bc82..8a84d97 100644 --- a/lib/basket_web/components/core_components.ex +++ b/lib/basket_web/components/core_components.ex @@ -526,7 +526,7 @@ defmodule BasketWeb.CoreComponents do @@ -542,7 +542,10 @@ defmodule BasketWeb.CoreComponents do >
- + <%= render_slot(col, @row_item.(row)) %>
diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index 431ba82..b91633d 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -8,7 +8,7 @@ defmodule BasketWeb.Live.Overview do alias Basket.Ticker alias BasketWeb.Components.NavRow - alias BasketWeb.Live.Overview.{Search, TickerAdd, TickerBarTable, TickerRow} + alias BasketWeb.Live.Overview.{Search, TickerAdd, TickerBarTable} alias BasketWeb.Presence on_mount {BasketWeb.Live.UserLiveAuth, :user} @@ -18,19 +18,21 @@ defmodule BasketWeb.Live.Overview do def mount(_, _, socket) do socket = assign(socket, :basket, []) - if connected?(socket) do - tickers = load_user_tickers(socket.assigns.user) - - if tickers != [] do - result = TickerAdd.call(tickers, socket.assigns.user.id) - new_socket = handle_ticker_add_result(result, socket) - {:ok, new_socket, temporary_assigns: @initial_temp_assigns} + socket = + if connected?(socket) do + tickers = load_user_tickers(socket.assigns.user) + + if tickers != [] do + result = TickerAdd.call(tickers, socket.assigns.user.id) + handle_ticker_add_result(result, socket) + else + socket + end else - {:ok, socket, temporary_assigns: @initial_temp_assigns} + socket end - else - {:ok, socket, temporary_assigns: @initial_temp_assigns} - end + + {:ok, socket, temporary_assigns: @initial_temp_assigns} end def handle_info({"ticker-add", %{"ticker" => ticker}}, socket) do @@ -38,9 +40,23 @@ defmodule BasketWeb.Live.Overview do {:noreply, socket} else Ticker.add(socket.assigns.user, ticker) - result = TickerAdd.call(ticker, socket.assigns.user.id) - new_socket = handle_ticker_add_result(result, socket, add_method: :append) - {:noreply, new_socket} + + case TickerAdd.call(ticker, socket.assigns.user.id) do + {:error, error} -> + Logger.error("Could not add ticker: #{error}") + {:noreply, socket} + + {:ok, %{bars: [bars]}} -> + {:noreply, push_event(socket, "ticker-added", %{bars: bars})} + # IO.inspect(result, label: "RESULT") + # new_socket = handle_ticker_add_result(result, socket) + # {:noreply, new_socket} + end + + # IO.inspect(result, label: "RESULT") + # new_socket = handle_ticker_add_result(result, socket) + # new_socket = push_event(socket, "ticker-added", %{ticker: ticker}) + # {:noreply, new_socket} end end @@ -48,7 +64,8 @@ defmodule BasketWeb.Live.Overview do %Phoenix.Socket.Broadcast{topic: _topic, event: "ticker-update", payload: payload}, socket ) do - updated_socket = update(socket, :basket, fn _basket -> [TickerRow.new(payload)] end) + # updated_socket = update(socket, :basket, fn _basket -> [TickerRow.new(payload)] end) + updated_socket = push_event(socket, "ticker-update-received", payload) {:noreply, updated_socket} end @@ -94,31 +111,16 @@ defmodule BasketWeb.Live.Overview do end end - # Define the default value for opts here, in the header. - defp handle_ticker_add_result(result, socket, opts \\ []) - defp handle_ticker_add_result( - {:ok, %{bars: bar_rows, tickers_not_found: tickers_not_found}}, - socket, - _opts + {:ok, %{bars: bar_rows, tickers_not_found: _tickers_not_found}}, + socket ) do - socket = - if tickers_not_found != [] do - put_flash( - socket, - :info, - "No data for tickers: #{Enum.join(tickers_not_found, ", ")}" - ) - else - socket - end - - update(socket, :basket, fn _basket -> bar_rows end) - end + # IO.inspect(bar_rows, label: "BAR_ROWS") - defp handle_ticker_add_result({:error, error}, socket, _opts) do - Logger.error("Could not subscribe to ticker: #{error}") - socket + update(socket, :basket, fn basket -> + # IO.inspect(basket, label: "BASKET") + bar_rows + end) end defp tickers(socket), do: Enum.map(socket.assigns.basket, fn row -> row.ticker end) From ab586a614efa80d149a89bf7e10aa8c88d126d5f Mon Sep 17 00:00:00 2001 From: daveminer Date: Wed, 3 Apr 2024 18:02:29 -0400 Subject: [PATCH 2/3] convert to liveview-managed tickers --- assets/js/_hooks/CellValueStore.js | 3 - assets/js/_hooks/TickerHandler.js | 121 ------------------- assets/js/_hooks/index.js | 2 - lib/basket_web/components/core_components.ex | 2 +- lib/basket_web/live/overview.ex | 53 ++++---- 5 files changed, 24 insertions(+), 157 deletions(-) delete mode 100644 assets/js/_hooks/TickerHandler.js diff --git a/assets/js/_hooks/CellValueStore.js b/assets/js/_hooks/CellValueStore.js index d6c09ba..d84cdd3 100644 --- a/assets/js/_hooks/CellValueStore.js +++ b/assets/js/_hooks/CellValueStore.js @@ -1,12 +1,9 @@ export const CellValueStore = { mounted() { this.handleEvent("ticker-update-received", (values) => { - console.log(values, "VALUES") const id = values["S"]; // select the table row with id equal to id - console.log(id, "ID") const row = document.querySelector(`tr[id="${id}"]`); - console.log(row, "ROW") this.highlightChanges(row, values); }); }, diff --git a/assets/js/_hooks/TickerHandler.js b/assets/js/_hooks/TickerHandler.js deleted file mode 100644 index 4a62048..0000000 --- a/assets/js/_hooks/TickerHandler.js +++ /dev/null @@ -1,121 +0,0 @@ -export const TickerHandler = { - mounted() { - this.handleEvent("ticker-removed", ({ ticker }) => { - let element = document.getElementById(ticker); - if (element) element.remove(); - }) - - this.handleEvent("ticker-added", ({ bars }) => { - let table = document.getElementById("ticker-table"); - if (table) { - let rows = Array.from(table.rows); - rows.push(createRow(bars)) - - // Sort the rows by id - rows.sort((a, b) => a.id.localeCompare(b.id)); - - // Remove all rows from the table - while (table.firstChild) { - table.removeChild(table.firstChild); - } - - // Append the sorted rows to the table - for (let row of rows) { - table.appendChild(row); - } - } - }) - } -} - -function createRow(bars) { - console.log(bars, "BARS") - let row = document.createElement("tr"); - // add an id to the row - row.id = bars.ticker; - - // add classes to the row - row.classList.add("group"); - row.classList.add("hover:bg-zinc-50"); - row.classList.add("text-sm"); - row.classList.add("text-zinc-700"); - - let tickerTd = createCell(bars) - row.appendChild(tickerTd); - - const labelledValues = buildLabels(bars); - - for (idx in labelledValues) { - //console.log(td, "TD") - //let td = createCell(cell, bars.ticker) - row.appendChild(labelledValues[idx]); - } - - let deleteTd = createCell('x') - row.appendChild(deleteTd) - // for (cell in bars) { - // let td = createCell(cell) - // row.appendChild(td); - // } - - return row -} - -function buildLabels(bars) { - const labels = [ - { label: "Open", value: bars.open }, - { label: "High", value: bars.high }, - { label: "Low", value: bars.low }, - { label: "Close", value: bars.close }, - { label: "Volume", value: bars.volume }, - { label: "Timestamp", value: bars.timestamp } - ] - - let cells = []; - for (idx in labels) { - const { label, value } = labels[idx]; - let td = createCell(label, bars.ticker, value) - cells.push(td); - } - - return cells; -} - -function createCell(label, ticker, value) { - let td = document.createElement("td"); - td.classList.add("relative"); - td.classList.add("p-0"); - td.classList.add("text-center"); - td.classList.add("hover:cursor-pointer"); - - // add a data-key attribute to the cell - td.setAttribute("data-key", `${ticker}_${label}`); - - // add a div to the cell inner - let div = document.createElement("div"); - div.classList.add("block"); - div.classList.add("py-4"); - div.classList.add("pr-6"); - - let span = document.createElement("span"); - span.classList.add("absolute"); - span.classList.add("-inset-y-px"); - span.classList.add("right-0"); - span.classList.add("-left-4"); - span.classList.add("group-hover:bg-zinc-50"); - span.classList.add("sm:rounded-l-xl"); - - let contentSpan = document.createElement("span"); - contentSpan.classList.add(`${ticker}_${label}-content-slot`) - contentSpan.classList.add("relative"); - contentSpan.classList.add("font-semibold"); - contentSpan.classList.add("text-zinc-900"); - contentSpan.innerText = value; - - div.appendChild(span); - div.appendChild(contentSpan); - - td.appendChild(div); - - return td; -} diff --git a/assets/js/_hooks/index.js b/assets/js/_hooks/index.js index c0678e1..a6b1a2a 100644 --- a/assets/js/_hooks/index.js +++ b/assets/js/_hooks/index.js @@ -1,8 +1,6 @@ import { CellValueStore } from './CellValueStore' -import { TickerHandler } from './TickerHandler' const Hooks = {} Hooks.CellValueStore = CellValueStore -Hooks.TickerHandler = TickerHandler export default Hooks diff --git a/lib/basket_web/components/core_components.ex b/lib/basket_web/components/core_components.ex index 8a84d97..4fe8a62 100644 --- a/lib/basket_web/components/core_components.ex +++ b/lib/basket_web/components/core_components.ex @@ -512,7 +512,7 @@ defmodule BasketWeb.CoreComponents do ~H"""
- +
diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index b91633d..fd86876 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -13,8 +13,6 @@ defmodule BasketWeb.Live.Overview do on_mount {BasketWeb.Live.UserLiveAuth, :user} - @initial_temp_assigns [basket: []] - def mount(_, _, socket) do socket = assign(socket, :basket, []) @@ -23,8 +21,7 @@ defmodule BasketWeb.Live.Overview do tickers = load_user_tickers(socket.assigns.user) if tickers != [] do - result = TickerAdd.call(tickers, socket.assigns.user.id) - handle_ticker_add_result(result, socket) + add_ticker(socket, tickers) else socket end @@ -32,7 +29,7 @@ defmodule BasketWeb.Live.Overview do socket end - {:ok, socket, temporary_assigns: @initial_temp_assigns} + {:ok, socket} end def handle_info({"ticker-add", %{"ticker" => ticker}}, socket) do @@ -41,22 +38,7 @@ defmodule BasketWeb.Live.Overview do else Ticker.add(socket.assigns.user, ticker) - case TickerAdd.call(ticker, socket.assigns.user.id) do - {:error, error} -> - Logger.error("Could not add ticker: #{error}") - {:noreply, socket} - - {:ok, %{bars: [bars]}} -> - {:noreply, push_event(socket, "ticker-added", %{bars: bars})} - # IO.inspect(result, label: "RESULT") - # new_socket = handle_ticker_add_result(result, socket) - # {:noreply, new_socket} - end - - # IO.inspect(result, label: "RESULT") - # new_socket = handle_ticker_add_result(result, socket) - # new_socket = push_event(socket, "ticker-added", %{ticker: ticker}) - # {:noreply, new_socket} + {:noreply, add_ticker(socket, ticker)} end end @@ -64,7 +46,6 @@ defmodule BasketWeb.Live.Overview do %Phoenix.Socket.Broadcast{topic: _topic, event: "ticker-update", payload: payload}, socket ) do - # updated_socket = update(socket, :basket, fn _basket -> [TickerRow.new(payload)] end) updated_socket = push_event(socket, "ticker-update-received", payload) {:noreply, updated_socket} @@ -84,7 +65,12 @@ defmodule BasketWeb.Live.Overview do Ticker.remove(socket.assigns.user, ticker) Presence.untrack(self(), "bars-#{ticker}", socket.assigns.user.id) - socket = push_event(socket, "ticker-removed", %{ticker: ticker}) + socket = + assign( + socket, + :basket, + Enum.reject(socket.assigns.basket, fn row -> row.ticker == ticker end) + ) {:noreply, socket} end @@ -101,6 +87,17 @@ defmodule BasketWeb.Live.Overview do """ end + defp add_ticker(socket, tickers) do + case TickerAdd.call(tickers, socket.assigns.user.id) do + {:error, error} -> + Logger.error("Could not add ticker: #{error}") + socket + + {:ok, %{bars: bars}} -> + handle_ticker_add_result(bars, socket) + end + end + defp load_user_tickers(user) do case Ticker.for_user(user) do [] -> @@ -112,15 +109,11 @@ defmodule BasketWeb.Live.Overview do end defp handle_ticker_add_result( - {:ok, %{bars: bar_rows, tickers_not_found: _tickers_not_found}}, + bar_rows, socket ) do - # IO.inspect(bar_rows, label: "BAR_ROWS") - - update(socket, :basket, fn basket -> - # IO.inspect(basket, label: "BASKET") - bar_rows - end) + new_rows = Enum.sort(socket.assigns.basket ++ bar_rows, fn a, b -> a.ticker < b.ticker end) + assign(socket, :basket, new_rows) end defp tickers(socket), do: Enum.map(socket.assigns.basket, fn row -> row.ticker end) From c0b269114a01c6f64929643e5275f0c47f11e4ac Mon Sep 17 00:00:00 2001 From: daveminer Date: Wed, 3 Apr 2024 19:33:23 -0400 Subject: [PATCH 3/3] update tests for server-side ticker tracking --- test/basket_web/live/overview_test.exs | 41 -------------------------- 1 file changed, 41 deletions(-) diff --git a/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index dc3de55..6395a7a 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -99,46 +99,5 @@ defmodule BasketWeb.Live.OverviewTest do {:ok, _empty_view, _html} = live(conn, "/") assert render(view) =~ " expect(:latest_quote, fn _ -> {:ok, %{"bars" => build(:bars_payload)}} end) - - Basket.Websocket.MockClient |> expect(:send_frame, 2, fn _, _ -> :ok end) - - {:ok, view, _html} = live(conn, "/") - - assert render(view) =~ - "\n 43025\n " - - # Send update - send( - view.pid, - %Phoenix.Socket.Broadcast{ - topic: "bars-ALPHA", - event: "ticker-update", - payload: %{ - "S" => "XYZ", - "T" => "b", - "c" => 191.285, - "h" => 191.37, - "l" => 191.23, - "n" => 50, - "o" => 191.23, - "t" => "2023-11-20T16:24:00Z", - "v" => 5433, - "vw" => 191.328043 - } - } - ) - - # Remove the last ticker so the Presence topic is left before the mock is gone. - render_hook(view, "ticker-remove", %{"ticker" => "ALPHA"}) - - # Check for update - assert render(view) =~ "\n 5433\n " - end end end