diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/assets/images/an/be_event_processing.png b/assets/images/an/be_event_processing.png new file mode 100644 index 0000000..bf4b340 Binary files /dev/null and b/assets/images/an/be_event_processing.png differ diff --git a/assets/images/an/monitoring.png b/assets/images/an/monitoring.png new file mode 100644 index 0000000..e53ec72 Binary files /dev/null and b/assets/images/an/monitoring.png differ diff --git a/assets/images/an/offer_traffic_flow_cpa.png b/assets/images/an/offer_traffic_flow_cpa.png new file mode 100644 index 0000000..cfd0566 Binary files /dev/null and b/assets/images/an/offer_traffic_flow_cpa.png differ diff --git a/assets/images/an/offer_traffic_flow_cpc.png b/assets/images/an/offer_traffic_flow_cpc.png new file mode 100644 index 0000000..6096ba0 Binary files /dev/null and b/assets/images/an/offer_traffic_flow_cpc.png differ diff --git a/assets/images/an/overall_arch.png b/assets/images/an/overall_arch.png new file mode 100644 index 0000000..c0a465f Binary files /dev/null and b/assets/images/an/overall_arch.png differ diff --git a/assets/images/an/overview.png b/assets/images/an/overview.png new file mode 100644 index 0000000..a9cdbee Binary files /dev/null and b/assets/images/an/overview.png differ diff --git a/assets/images/an/path_flow.png b/assets/images/an/path_flow.png new file mode 100644 index 0000000..8e1d225 Binary files /dev/null and b/assets/images/an/path_flow.png differ diff --git a/assets/images/an/stack.png b/assets/images/an/stack.png new file mode 100644 index 0000000..dbc211e Binary files /dev/null and b/assets/images/an/stack.png differ diff --git a/assets/images/an/ui_arch.png b/assets/images/an/ui_arch.png new file mode 100644 index 0000000..1f06a3e Binary files /dev/null and b/assets/images/an/ui_arch.png differ diff --git a/assets/images/an/ui_old_netadmin.png b/assets/images/an/ui_old_netadmin.png new file mode 100644 index 0000000..d241735 Binary files /dev/null and b/assets/images/an/ui_old_netadmin.png differ diff --git a/assets/images/an/ui_tech.png b/assets/images/an/ui_tech.png new file mode 100644 index 0000000..337d8ea Binary files /dev/null and b/assets/images/an/ui_tech.png differ diff --git a/assets/images/market_mate/dashboard.png b/assets/images/market_mate/dashboard.png new file mode 100644 index 0000000..cac6d7c Binary files /dev/null and b/assets/images/market_mate/dashboard.png differ diff --git a/assets/images/market_mate/import_fidelity.png b/assets/images/market_mate/import_fidelity.png new file mode 100644 index 0000000..c9a190a Binary files /dev/null and b/assets/images/market_mate/import_fidelity.png differ diff --git a/assets/images/market_mate/landing_page.png b/assets/images/market_mate/landing_page.png new file mode 100644 index 0000000..0877764 Binary files /dev/null and b/assets/images/market_mate/landing_page.png differ diff --git a/assets/images/market_mate/live_dash.gif b/assets/images/market_mate/live_dash.gif new file mode 100644 index 0000000..2d205bf Binary files /dev/null and b/assets/images/market_mate/live_dash.gif differ diff --git a/assets/images/market_mate/live_dash.mp4 b/assets/images/market_mate/live_dash.mp4 new file mode 100644 index 0000000..a24a09e Binary files /dev/null and b/assets/images/market_mate/live_dash.mp4 differ diff --git a/assets/images/market_mate/set_alert.png b/assets/images/market_mate/set_alert.png new file mode 100644 index 0000000..666f4e5 Binary files /dev/null and b/assets/images/market_mate/set_alert.png differ diff --git a/assets/images/market_mate/set_targets.png b/assets/images/market_mate/set_targets.png new file mode 100644 index 0000000..3cdcca1 Binary files /dev/null and b/assets/images/market_mate/set_targets.png differ diff --git a/assets/images/market_mate/sys_arch.png b/assets/images/market_mate/sys_arch.png new file mode 100644 index 0000000..f23f47d Binary files /dev/null and b/assets/images/market_mate/sys_arch.png differ diff --git a/assets/resume/kyle-neal-resume.pdf b/assets/resume/kyle-neal-resume.pdf index 42eeeab..b14357a 100644 Binary files a/assets/resume/kyle-neal-resume.pdf and b/assets/resume/kyle-neal-resume.pdf differ diff --git a/lib/revstack_web/live/whoami_live.ex b/lib/revstack_web/live/whoami_live.ex index e558361..b55d1f9 100644 --- a/lib/revstack_web/live/whoami_live.ex +++ b/lib/revstack_web/live/whoami_live.ex @@ -46,6 +46,96 @@ defmodule RevstackWeb.WhoamiLive do } ] + @marketmate_gallery_items [ + %{ + src: "/images/market_mate/live_dash.mp4", + title: "Live Portfolio Updates", + category: "Real-Time UI", + description: + "A live portfolio dashboard that reacts to market movement in real time, giving the user instant visibility into portfolio changes without leaving the page.", + highlights: [ + "Real-time stock updates flow directly into the portfolio experience.", + "Phoenix LiveView refreshes the UI instantly with server-rendered state updates.", + "An Erlang event pipeline powers the live update loop behind the dashboard." + ], + why_it_matters: + "This slide proves the product is not just a static dashboard. The value is immediate market awareness delivered in real time, which is the baseline for a tool that users can actually rely on throughout the trading day." + }, + %{ + src: "/images/market_mate/sys_arch.png", + title: "System Architecture", + category: "BEAM-Native Design", + description: + "MarketMate is intentionally split between MMEX, the Elixir + LiveView web layer, and MMERL, the Erlang/OTP engine responsible for long-running event-driven processing.", + highlights: [ + "MMEX owns the browser experience, LiveView state, and product presentation.", + "MMERL was chosen for concurrency, supervision trees, and fault-tolerant background processing.", + "Ash accelerates domain modeling with a clean resource structure and fast iteration.", + "The system keeps a clear separation of concerns between UI delivery and backend event processing." + ], + why_it_matters: + "The architecture slide shows deliberate system boundaries instead of an all-in-one app. That separation makes the product easier to evolve, easier to reason about, and better suited for long-running event workloads on the BEAM." + }, + %{ + src: "/images/market_mate/set_alert.png", + title: "Set Price Alerts", + category: "Alerting Pipeline", + description: + "Users configure price alerts in the web application, but alert evaluation and notification generation happen inside the Erlang engine where event processing belongs.", + highlights: [ + "Alerts are configured by the user in MMEX.", + "Alert conditions are evaluated inside MMERL.", + "Notifications are generated in Erlang and sent through a bridge back to MMEX.", + "Phoenix PubSub broadcasts the result so the UI reflects alert activity in real time." + ], + why_it_matters: + "Alerts are where system design turns into user utility. This flow demonstrates that MarketMate can take user intent, evaluate it continuously in the backend, and push actionable outcomes back to the interface without manual refreshes." + }, + %{ + src: "/images/market_mate/set_targets.png", + title: "Set Price Targets", + category: "Decision Support", + description: + "Price targets follow the same BEAM-native event pipeline as alerts, forming the foundation for a richer investment decision-support system.", + highlights: [ + "Target updates move through the bridge between MMERL and MMEX.", + "Phoenix PubSub keeps the LiveView interface in sync with target changes.", + "Future work includes a news ingestion layer and an AI agent for context-aware analysis.", + "The longer-term vision includes BUY / SELL / HOLD recommendations, intrinsic value modeling, and deeper financial metrics." + ], + why_it_matters: + "Targets expand the product from monitoring into decision support. This is the bridge from raw market tracking to a more opinionated financial assistant that can eventually combine targets, news, and valuation context." + }, + %{ + src: "/images/market_mate/import_fidelity.png", + title: "Portfolio Import", + category: "Data Ingestion", + description: + "The current MVP supports manual Fidelity portfolio import, giving users a pragmatic path to getting real holdings into the system quickly.", + highlights: [ + "Manual Fidelity import keeps the first version simple and operational.", + "The workflow is intentionally MVP-friendly so the product can validate core value before deeper integrations.", + "If API access becomes available, the next step is an automated sync pipeline." + ], + why_it_matters: + "Import is critical because product value depends on real user holdings, not demo data. This MVP workflow favors speed-to-utility first, while still leaving a clear path toward automated brokerage integration later." + }, + %{ + src: "/images/market_mate/landing_page.png", + title: "Product Vision", + category: "Flagship Vision", + description: + "MarketMate is being built as an intelligent personal financial advisor that combines portfolio awareness, alerts, news, and financial metrics into one decision-support experience.", + highlights: [ + "The product centers on portfolio awareness instead of generic market dashboards.", + "Alerts, news, and metrics are designed to converge into one operator-style workflow.", + "The long-term goal is stronger decision support for real users making real financial calls." + ], + why_it_matters: + "The vision slide anchors the entire walkthrough. It clarifies that MarketMate is being built as a cohesive financial operator experience, not a collection of isolated features, and that every subsystem is serving that larger product direction." + } + ] + @section_navigation_items [ %{id: "whoami-hero", label: "Hero"}, %{id: "whoami-experience", label: "Career Highlights"}, @@ -70,6 +160,9 @@ defmodule RevstackWeb.WhoamiLive do admin_gallery_open?: false, admin_gallery_index: 0, admin_gallery_images: @admin_gallery_images, + marketmate_gallery_open?: false, + marketmate_gallery_index: 0, + marketmate_gallery_items: @marketmate_gallery_items, section_nav_open?: false, section_navigation_items: @section_navigation_items, career_modal_open?: false, @@ -106,6 +199,34 @@ defmodule RevstackWeb.WhoamiLive do {:noreply, assign(socket, :admin_gallery_index, String.to_integer(index))} end + def handle_event("open_marketmate_gallery", _params, socket) do + {:noreply, assign(socket, marketmate_gallery_open?: true, marketmate_gallery_index: 0)} + end + + def handle_event("close_marketmate_gallery", _params, socket) do + {:noreply, assign(socket, marketmate_gallery_open?: false)} + end + + def handle_event("marketmate_gallery_prev", _params, socket) do + index = max(socket.assigns.marketmate_gallery_index - 1, 0) + {:noreply, assign(socket, :marketmate_gallery_index, index)} + end + + def handle_event("marketmate_gallery_next", _params, socket) do + max_index = gallery_max_index(socket.assigns.marketmate_gallery_items) + index = min(socket.assigns.marketmate_gallery_index + 1, max_index) + {:noreply, assign(socket, :marketmate_gallery_index, index)} + end + + def handle_event("marketmate_gallery_select", %{"index" => index}, socket) do + index = + index + |> String.to_integer() + |> clamp_gallery_index(socket.assigns.marketmate_gallery_items) + + {:noreply, assign(socket, :marketmate_gallery_index, index)} + end + def handle_event("toggle_section_nav", _params, socket) do {:noreply, assign(socket, :section_nav_open?, !socket.assigns.section_nav_open?)} end @@ -175,7 +296,7 @@ defmodule RevstackWeb.WhoamiLive do expanded_phase_id={@career_expanded_phase_id} /> <.technical_expertise_section /> - <.live_projects_section admin_gallery_images={@admin_gallery_images} /> + <.live_projects_section /> <.leadership_and_teamwork_section /> <.education_section /> <.personal_interests_section /> @@ -193,6 +314,11 @@ defmodule RevstackWeb.WhoamiLive do images={@admin_gallery_images} current_index={@admin_gallery_index} /> + <.marketmate_gallery_modal + :if={@marketmate_gallery_open?} + items={@marketmate_gallery_items} + current_index={@marketmate_gallery_index} + /> """ end @@ -215,7 +341,7 @@ defmodule RevstackWeb.WhoamiLive do id="whoami-skill-signature" class="mt-6 text-lg text-base-content/70 max-w-2xl mx-auto leading-relaxed" > - 12+ years building and owning revenue-critical, high-throughput production systems on the BEAM. Erlang/OTP, Elixir, Phoenix LiveView, and distributed data architecture at scale. + 10+ years building and owning revenue-critical, high-throughput production systems on the BEAM. Erlang/OTP, Elixir, Phoenix LiveView, and distributed data architecture at scale.

<%!-- Grouped proof points for faster recruiter scanning --%> @@ -226,7 +352,7 @@ defmodule RevstackWeb.WhoamiLive do

- <.icon name="hero-clock" class="size-4" /> 12+ Years on the BEAM + <.icon name="hero-clock" class="size-4" /> 10+ Years on the BEAM <.icon name="hero-user-group" class="size-4" /> 5 Engineers Led & Mentored @@ -243,7 +369,7 @@ defmodule RevstackWeb.WhoamiLive do

- <.icon name="hero-bolt" class="size-4" /> Supported 1.5M+ Events/Day + <.icon name="hero-bolt" class="size-4" /> Supported 10M+ daily events <.icon name="hero-currency-dollar" class="size-4" /> Powered $2.5M+/mo Revenue @@ -346,7 +472,7 @@ defmodule RevstackWeb.WhoamiLive do "Elasticsearch / OpenSearch analytics", "Cassandra", "Apache Spark (AWS EMR, scala)", - "Time-series data modeling (1.5M+ events/day)" + "Time-series data modeling (10M+ events/day)" ]} /> <.expertise_group @@ -515,37 +641,95 @@ defmodule RevstackWeb.WhoamiLive do

Beyond production systems, I continue to build and deploy independent projects exploring new ideas and technologies.

+

+ <.icon + name="hero-cursor-arrow-rays" + class="size-5 inline-block align-text-bottom" + /> Click a project to explore +

-
- <.project_card - id="project-handyman" - title="Hardcore Handyman" - subtitle="Full-stack Elixir lead-generation platform with SEO-driven service pages, conversion-focused design, and admin interface. Generated more inbound demand than the business could operationally support." - href="https://hardcorehandyman.fly.dev/" - icon="hero-wrench-screwdriver" - preview_src={~p"/images/hardcorehandyman_preview.png"} - tech={~w(Elixir Phoenix LiveView Ecto Swoosh Fly.io)} - /> - <.project_card - id="project-admin" - title="Admin Dashboard (for this site!)" - subtitle="Custom-built admin dashboard for managing leads and estimates. Features real-time data grids, filtering, status management, and single-user authentication." - href="https://github.com/kyle-neal/revstack" - icon="hero-cog-6-tooth" - preview_src={~p"/images/admin_panel/admin_dashboard.png"} - tech={~w(Elixir Phoenix LiveView Ash Postgres)} - on_click="open_admin_gallery" - /> - <.project_card - id="project-revenuelink" - title="RevenueLink" - subtitle="My personal business website and portfolio hub. Showcases my professional profile and services, and serves as a central point for inquiries and collaborations." - href="https://revenuelink.net/" - icon="hero-building-office-2" - preview_src={~p"/images/revenuelink_preview.png"} - tech={~w(Next.js ReactJS TailwindCSS Vercel)} - /> +
+
+
+
+

Completed Projects

+ + Featured Work + +
+

+ Production-ready launches, deployed experiments, and supporting projects that round out the broader portfolio. +

+
+ +
+ <.project_card + id="project-handyman" + title="Hardcore Handyman" + subtitle="Full-stack Elixir lead-generation platform with SEO-driven service pages, conversion-focused design, and admin interface. Generated more inbound demand than the business could operationally support." + href="https://hardcorehandyman.fly.dev/" + icon="hero-wrench-screwdriver" + preview_src={~p"/images/hardcorehandyman_preview.png"} + tech={~w(Elixir Phoenix LiveView Ecto Swoosh Fly.io)} + /> + <.project_card + id="project-admin" + title="Admin Dashboard (for this site!)" + subtitle="Custom-built admin dashboard for managing leads and estimates. Features real-time data grids, filtering, status management, and single-user authentication." + href="https://github.com/kyle-neal/revstack" + icon="hero-cog-6-tooth" + preview_src={~p"/images/admin_panel/admin_dashboard.png"} + tech={~w(Elixir Phoenix LiveView Ash Postgres)} + on_click="open_admin_gallery" + /> + <.project_card + id="project-revenuelink" + title="RevenueLink" + subtitle="My personal business website and portfolio hub. Showcases my professional profile and services, and serves as a central point for inquiries and collaborations." + href="https://revenuelink.net/" + icon="hero-building-office-2" + preview_src={~p"/images/revenuelink_preview.png"} + tech={~w(Next.js ReactJS TailwindCSS Vercel)} + /> +
+
+ +
+
+
+

In Progress Projects

+ + In Progress + +
+

+ Active projects currently under development. These highlight ongoing system design, architecture decisions, and evolving features. +

+
+ +
+ <.project_card + id="project-marketmate" + title="MarketMate" + subtitle="Real-time personal finance and portfolio monitoring system built with Elixir, Erlang, LiveView, and PostgreSQL. Designed with a BEAM-native architecture separating UI and event-driven backend processing." + href="https://github.com/kyle-neal/market_mate" + icon="hero-chart-bar" + preview_src={~p"/images/market_mate/dashboard.png"} + tech={["Elixir", "Erlang/OTP", "LiveView", "Ash", "PostgreSQL"]} + on_click="open_marketmate_gallery" + /> +
+
@@ -845,27 +1029,56 @@ defmodule RevstackWeb.WhoamiLive do |> assign_new(:preview_alt, fn -> "#{assigns.title} preview" end) |> assign_new(:tech, fn -> [] end) |> assign_new(:on_click, fn -> nil end) + |> assign_new(:badge, fn -> nil end) + |> assign_new(:featured?, fn -> false end) + |> assign(:preview_video?, preview_video?(assigns[:preview_src])) ~H""" <%= if @on_click do %> +
+ + + +
+
+
+
+
+
+ + + +
+ + {@current_item.category} + +
+ + <%= if preview_video?(@current_item.src) do %> + + <% else %> + {@current_item.title} + <% end %> + + + + +
+ +
+ +
+
+ +
+
+ + {@current_item.category} + + + MarketMate Walkthrough + +
+ +

+ {@current_item.title} +

+

+ {@current_item.description} +

+ +
+

+ Architecture Notes +

+
    +
  • + + <.icon name="hero-chevron-right" class="size-3" /> + + {highlight} +
  • +
+
+ +
+

+ Why It Matters +

+

+ {@current_item.why_it_matters} +

+
+
+
+
+ + + + """ + end + defp expertise_group(assigns) do ~H"""
@@ -2157,7 +2627,7 @@ defmodule RevstackWeb.WhoamiLive do %{value: "3-node", label: "ES cluster"}, %{value: "~600 TB", label: "Cassandra footprint"}, %{value: "~600 TB", label: "ES analytics"}, - %{value: "12+", label: "years ownership"} + %{value: "10+", label: "years ownership"} ], team: %{ description: @@ -2429,4 +2899,29 @@ defmodule RevstackWeb.WhoamiLive do nil end end + + defp gallery_max_index(items) do + max(length(items) - 1, 0) + end + + defp clamp_gallery_index(index, items) do + index + |> max(0) + |> min(gallery_max_index(items)) + end + + defp preview_video?(nil), do: false + + defp preview_video?(src) do + Path.extname(src) in [".mp4", ".webm", ".mov"] + end + + defp preview_mime_type(src) do + case Path.extname(src) do + ".mp4" -> "video/mp4" + ".webm" -> "video/webm" + ".mov" -> "video/quicktime" + _ -> "video/mp4" + end + end end diff --git a/mix.exs b/mix.exs index 51d5603..6c98c5c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Revstack.MixProject do def project do [ app: :revstack, - version: "1.3.0", + version: "1.4.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/priv/resume/kyle-neal-resume.pdf b/priv/resume/kyle-neal-resume.pdf index 42eeeab..b14357a 100644 Binary files a/priv/resume/kyle-neal-resume.pdf and b/priv/resume/kyle-neal-resume.pdf differ diff --git a/test/revstack_web/whoami_live_layout_test.exs b/test/revstack_web/whoami_live_layout_test.exs index 4a4aab7..b7137e2 100644 --- a/test/revstack_web/whoami_live_layout_test.exs +++ b/test/revstack_web/whoami_live_layout_test.exs @@ -36,7 +36,7 @@ defmodule RevstackWeb.WhoamiLiveLayoutTest do assert has_element?(view, "#whoami-proof-points-scale") assert has_element?(view, "#whoami-proof-points-strengths p", "What I Bring") assert has_element?(view, "#whoami-proof-points-scale p", "Systems I've Built") - assert has_element?(view, "#whoami-proof-points-strengths span", "12+ Years on the BEAM") + assert has_element?(view, "#whoami-proof-points-strengths span", "10+ Years on the BEAM") assert has_element?( view, @@ -50,7 +50,12 @@ defmodule RevstackWeb.WhoamiLiveLayoutTest do "End-to-End Platform Ownership" ) - assert has_element?(view, "#whoami-proof-points-scale span", "Supported 1.5M+ Events/Day") + assert has_element?( + view, + "#whoami-proof-points-scale span", + "Supported 10M+ daily events" + ) + assert has_element?(view, "#whoami-proof-points-scale span", "Powered $2.5M+/mo Revenue") assert has_element?( diff --git a/test/revstack_web/whoami_live_projects_test.exs b/test/revstack_web/whoami_live_projects_test.exs index 26ac874..d1d162c 100644 --- a/test/revstack_web/whoami_live_projects_test.exs +++ b/test/revstack_web/whoami_live_projects_test.exs @@ -6,15 +6,41 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do @moduletag capture_log: true @active_gallery_image_count 7 + @marketmate_gallery_image_count 6 describe "live projects section" do - test "renders the live projects section with all three cards", %{conn: conn} do + test "renders completed projects first, followed by in-progress projects", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/") assert has_element?(view, "#live-projects") + assert has_element?(view, "#live-projects-completed") + assert has_element?(view, "#live-projects-in-progress") + assert has_element?(view, "#live-projects-in-progress-badge", "In Progress") + assert has_element?(view, "#project-marketmate") assert has_element?(view, "#project-handyman") assert has_element?(view, "#project-admin") assert has_element?(view, "#project-revenuelink") + assert has_element?(view, "#live-projects p", "Click a project to explore") + + html = render(view) + assert String.contains?(html, "Completed Projects") + assert String.contains?(html, "In Progress Projects") + completed_position = :binary.match(html, "live-projects-completed") + in_progress_position = :binary.match(html, "live-projects-in-progress") + + assert completed_position != :nomatch + assert in_progress_position != :nomatch + assert elem(completed_position, 0) < elem(in_progress_position, 0) + end + + test "marketmate card uses the dashboard preview image", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/") + + document = LazyHTML.from_fragment(html) + marketmate = LazyHTML.query_by_id(document, "project-marketmate") + tree = LazyHTML.to_tree(marketmate, sort_attributes: true, skip_whitespace_nodes: true) + + assert has_src_containing?(tree, "market_mate/dashboard.png") end test "hardcore handyman card uses local preview image", %{conn: conn} do @@ -61,6 +87,17 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do assert has_element?(view, "button#project-admin[phx-click='open_admin_gallery']") end + test "marketmate card is a button without a duplicate card badge", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + assert has_element?( + view, + "button#project-marketmate[phx-click='open_marketmate_gallery']" + ) + + refute has_element?(view, "#project-marketmate-badge") + end + test "admin panel card shows the GitHub URL in the card preview", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/") @@ -72,6 +109,150 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do end end + describe "marketmate gallery modal" do + test "modal is not rendered on initial page load", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + refute has_element?(view, "#marketmate-gallery-modal") + end + + test "clicking marketmate opens the gallery modal", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view + |> element("button#project-marketmate") + |> render_click() + + assert has_element?(view, "#marketmate-gallery-modal") + end + + test "modal shows the first walkthrough slide and source link by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + + html = render(view) + assert html =~ "1 of #{@marketmate_gallery_image_count}" + assert html =~ "Live Portfolio Updates" + assert html =~ "The value is immediate market awareness delivered in real time" + + assert has_element?( + view, + "#marketmate-gallery-source-link[href='https://github.com/kyle-neal/market_mate'][target='_blank'][rel='noopener noreferrer']", + "View Source Code on GitHub" + ) + end + + test "modal renders the first walkthrough slide as an mp4 video", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + + document = LazyHTML.from_fragment(render(view)) + modal = LazyHTML.query_by_id(document, "marketmate-gallery-modal") + tree = LazyHTML.to_tree(modal, sort_attributes: true, skip_whitespace_nodes: true) + + assert has_tag_with_attribute?(tree, "video", "autoplay") + assert has_tag_with_src_containing?(tree, "source", "market_mate/live_dash.mp4") + end + + test "modal has LockBodyScroll phx-hook", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + + html = render(view) + document = LazyHTML.from_fragment(html) + modal = LazyHTML.query_by_id(document, "marketmate-gallery-modal") + tree = LazyHTML.to_tree(modal, sort_attributes: true, skip_whitespace_nodes: true) + + assert has_hook_attribute?(tree, "LockBodyScroll") + end + + test "next button advances to the architecture slide", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + view |> element("#marketmate-gallery-next") |> render_click() + + html = render(view) + assert html =~ "2 of #{@marketmate_gallery_image_count}" + assert html =~ "System Architecture" + assert html =~ "MMEX" + assert html =~ "MMERL" + assert html =~ "The architecture slide shows deliberate system boundaries" + end + + test "previous button returns to the prior slide", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + view |> element("#marketmate-gallery-next") |> render_click() + view |> element("#marketmate-gallery-prev") |> render_click() + + html = render(view) + assert html =~ "1 of #{@marketmate_gallery_image_count}" + assert html =~ "Live Portfolio Updates" + end + + test "thumbnail selection jumps to the selected walkthrough item", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + + view + |> element("#marketmate-gallery-thumb-5") + |> render_click() + + html = render(view) + assert html =~ "6 of #{@marketmate_gallery_image_count}" + assert html =~ "Product Vision" + assert html =~ "intelligent personal financial advisor" + assert html =~ "The vision slide anchors the entire walkthrough" + end + + test "close button dismisses the modal", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + assert has_element?(view, "#marketmate-gallery-modal") + + view |> element("#close-marketmate-gallery") |> render_click() + refute has_element?(view, "#marketmate-gallery-modal") + end + + test "backdrop click closes the modal", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + assert has_element?(view, "#marketmate-gallery-modal") + + html = render(view) + document = LazyHTML.from_fragment(html) + modal = LazyHTML.query_by_id(document, "marketmate-gallery-modal") + tree = LazyHTML.to_tree(modal, sort_attributes: true, skip_whitespace_nodes: true) + + assert has_backdrop_close?(tree, "close_marketmate_gallery") + end + + test "previous button is hidden on the first slide and next button is hidden on the last slide", + %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/") + + view |> element("button#project-marketmate") |> render_click() + + refute has_element?(view, "#marketmate-gallery-prev") + assert has_element?(view, "#marketmate-gallery-next") + + view + |> element("#marketmate-gallery-thumb-5") + |> render_click() + + assert has_element?(view, "#marketmate-gallery-prev") + refute has_element?(view, "#marketmate-gallery-next") + end + end + describe "leadership and teamwork section" do test "renders the leadership and teamwork collaboration narrative", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/whoami") @@ -313,7 +494,7 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do modal = LazyHTML.query_by_id(document, "admin-gallery-modal") tree = LazyHTML.to_tree(modal, sort_attributes: true, skip_whitespace_nodes: true) - assert has_backdrop_close?(tree) + assert has_backdrop_close?(tree, "close_admin_gallery") end test "modal shows thumbnail strip with all images", %{conn: conn} do @@ -402,7 +583,7 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do end end - defp has_backdrop_close?(tree) do + defp has_backdrop_close?(tree, event_name) do find_in_tree(tree, fn {_tag, attrs, _children} -> has_class = @@ -413,7 +594,7 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do has_close = Enum.any?(attrs, fn - {"phx-click", "close_admin_gallery"} -> true + {"phx-click", ^event_name} -> true _ -> false end) @@ -433,4 +614,39 @@ defmodule RevstackWeb.WhoamiLiveProjectsTest do end defp find_in_tree(_other, _predicate), do: false + + defp has_tag_with_src_containing?(tree, tag_name, substring) when is_list(tree) do + Enum.any?(tree, &has_tag_with_src_containing?(&1, tag_name, substring)) + end + + defp has_tag_with_src_containing?({tag, attrs, children}, tag_name, substring) do + src_match = + tag == tag_name && + Enum.any?(attrs, fn + {"src", src} -> String.contains?(src, substring) + _ -> false + end) + + src_match or has_tag_with_src_containing?(children, tag_name, substring) + end + + defp has_tag_with_src_containing?(_other, _tag_name, _substring), do: false + + defp has_tag_with_attribute?(tree, tag_name, attribute_name) when is_list(tree) do + Enum.any?(tree, &has_tag_with_attribute?(&1, tag_name, attribute_name)) + end + + defp has_tag_with_attribute?({tag, attrs, children}, tag_name, attribute_name) do + attr_match = + tag == tag_name && + Enum.any?(attrs, fn + {^attribute_name, _value} -> true + {^attribute_name, nil} -> true + _ -> false + end) + + attr_match or has_tag_with_attribute?(children, tag_name, attribute_name) + end + + defp has_tag_with_attribute?(_other, _tag_name, _attribute_name), do: false end