diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 90a1f1054..844b5e066 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,24 @@ class PagesController < ApplicationController include Periodable + # Per-widget dashboard layout guardrails. Deterministic defaults the masonry + # packer reads; users may override a grow widget's height via presets. + # col_span: "single" | "full" (full spans both columns in 2-col mode) + # grow: true for charts that should fill an allotted height, + # false for content-sized widgets (tables, stat grids) + # min_height: floor in px + DASHBOARD_SECTION_LAYOUTS = { + "cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true }, + "outflows_donut" => { col_span: "single", grow: false, min_height: 0 }, + "investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }, + "net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true }, + "balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true } + }.freeze + + # Selectable height presets (px) for grow widgets. + DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze + DEFAULT_HEIGHT_PRESET = "auto" + skip_authentication only: %i[redis_configuration_error privacy terms] before_action :ensure_intro_guest!, only: :intro @@ -78,8 +96,9 @@ class PagesController < ApplicationController def preferences_params prefs = params.require(:preferences) {}.tap do |permitted| - permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections] - permitted["section_order"] = prefs[:section_order] if prefs[:section_order] + permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h) + permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array) + permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h) end end @@ -89,6 +108,7 @@ class PagesController < ApplicationController key: "cashflow_sankey", title: "pages.dashboard.cashflow_sankey.title", partial: "pages/dashboard/cashflow_sankey", + layout: section_layout("cashflow_sankey"), locals: { sankey_data: @cashflow_sankey_data, period: @period }, visible: @accounts.any?, collapsible: true @@ -97,6 +117,7 @@ class PagesController < ApplicationController key: "outflows_donut", title: "pages.dashboard.outflows_donut.title", partial: "pages/dashboard/outflows_donut", + layout: section_layout("outflows_donut"), locals: { outflows_data: @outflows_data, period: @period }, visible: @accounts.any? && @outflows_data[:categories].present?, collapsible: true @@ -105,6 +126,7 @@ class PagesController < ApplicationController key: "investment_summary", title: "pages.dashboard.investment_summary.title", partial: "pages/dashboard/investment_summary", + layout: section_layout("investment_summary"), locals: { investment_statement: @investment_statement, period: @period }, visible: @accounts.any? && @investment_statement.investment_accounts.any?, collapsible: true @@ -113,6 +135,7 @@ class PagesController < ApplicationController key: "net_worth_chart", title: "pages.dashboard.net_worth_chart.title", partial: "pages/dashboard/net_worth_chart", + layout: section_layout("net_worth_chart"), locals: { balance_sheet: @balance_sheet, period: @period }, visible: @accounts.any?, collapsible: true @@ -121,6 +144,7 @@ class PagesController < ApplicationController key: "balance_sheet", title: "pages.dashboard.balance_sheet.title", partial: "pages/dashboard/balance_sheet", + layout: section_layout("balance_sheet"), locals: { balance_sheet: @balance_sheet }, visible: @accounts.any?, collapsible: true @@ -141,6 +165,22 @@ class PagesController < ApplicationController ordered_sections end + # Resolves a section's layout guardrails, applying the user's height preset + # override (falling back to the deterministic default) for grow widgets. + def section_layout(key) + base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false }) + preset = Current.user.dashboard_section_height(key) + preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset) + + col_span = base[:col_span] + if base[:width_toggle] + user_span = Current.user.dashboard_section_width(key) + col_span = user_span if %w[single full].include?(user_span) + end + + base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset)) + end + def github_provider Provider::Registry.get_provider(:github) end diff --git a/app/javascript/controllers/dashboard_masonry_controller.js b/app/javascript/controllers/dashboard_masonry_controller.js new file mode 100644 index 000000000..99c2343b1 --- /dev/null +++ b/app/javascript/controllers/dashboard_masonry_controller.js @@ -0,0 +1,82 @@ +import { Controller } from "@hotwired/stimulus"; + +// Packs variable-height dashboard cards into a tight masonry layout by computing +// each card's grid row span from its measured height, letting `grid-auto-flow: +// dense` fill the gaps. Active only when the grid is actually multi-column; in +// single-column mode it clears the spans and normal block flow takes over. +// +// The DOM stays a single flat list of
children, so +// the dashboard-sortable controller (drag / keyboard reorder) is unaffected — +// reordering just moves a node and the CSS re-packs. +export default class extends Controller { + connect() { + this._scheduleLayout = this._scheduleLayout.bind(this); + this._frame = null; + this._resizeObserver = new ResizeObserver(this._scheduleLayout); + + this._cards().forEach((card) => this._resizeObserver.observe(card)); + this._scheduleLayout(); + + // Re-pack after the dashboard turbo frame re-renders (period change, reorder + // save) and on viewport changes that may cross the column breakpoint. + this.element.addEventListener("turbo:frame-load", this._scheduleLayout); + window.addEventListener("resize", this._scheduleLayout); + } + + disconnect() { + this._resizeObserver?.disconnect(); + this.element.removeEventListener("turbo:frame-load", this._scheduleLayout); + window.removeEventListener("resize", this._scheduleLayout); + if (this._frame) cancelAnimationFrame(this._frame); + this._cards().forEach((card) => { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + }); + } + + _cards() { + return Array.from( + this.element.querySelectorAll(":scope > [data-section-key]"), + ); + } + + _scheduleLayout() { + if (this._frame) cancelAnimationFrame(this._frame); + this._frame = requestAnimationFrame(() => this._layout()); + } + + _layout() { + const styles = getComputedStyle(this.element); + const columns = styles.gridTemplateColumns + .split(" ") + .filter(Boolean).length; + + // Single column: let natural block flow handle it, clear any stale spans. + if (columns < 2) { + this._cards().forEach((card) => { + if (card.dataset.masonrySpan) { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + } + }); + return; + } + + // The row gap is zeroed in masonry mode (2xl:gap-y-0). We reproduce a uniform + // inter-card gap by padding each card's span with empty tracks equal to the + // column gap. Integer row-spans over a non-zero row gap otherwise overshoot + // by up to one gap's worth of slack, giving visibly uneven vertical gaps. + const rowUnit = Number.parseFloat(styles.gridAutoRows) || 1; + const gap = Number.parseFloat(styles.columnGap) || 0; + const gapTracks = Math.round(gap / rowUnit); + + this._cards().forEach((card) => { + const height = card.getBoundingClientRect().height; + const span = Math.max(1, Math.ceil(height / rowUnit) + gapTracks); + if (card.dataset.masonrySpan !== String(span)) { + card.style.gridRowEnd = `span ${span}`; + card.dataset.masonrySpan = String(span); + } + }); + } +} diff --git a/app/javascript/controllers/dashboard_widget_size_controller.js b/app/javascript/controllers/dashboard_widget_size_controller.js new file mode 100644 index 000000000..57c87cf5d --- /dev/null +++ b/app/javascript/controllers/dashboard_widget_size_controller.js @@ -0,0 +1,110 @@ +import { Controller } from "@hotwired/stimulus"; + +// Per-widget layout picker (mounted on the
menu). Optimistically applies +// the choice for instant feedback, then persists it fire-and-forget — mirroring how +// collapse / reorder already save dashboard preferences: +// +// - Height: sets the `--dash-widget-h` CSS var; the chart redraws via its own +// ResizeObserver and dashboard-masonry re-packs from the height change. +// - Width: toggles the `2xl:col-span-2` class; the CSS grid re-flows and a +// synthetic resize nudges dashboard-masonry to re-pack. +export default class extends Controller { + static values = { sectionKey: String }; + + connect() { + this._closeOnOutsideClick = this._closeOnOutsideClick.bind(this); + document.addEventListener("click", this._closeOnOutsideClick); + } + + disconnect() { + document.removeEventListener("click", this._closeOnOutsideClick); + } + + // Keep Enter/Space/arrow keydowns inside the menu from bubbling to the + // section-level dashboard-sortable handler, which would otherwise hijack them + // to toggle keyboard reorder mode. + stopKeydown(event) { + event.stopPropagation(); + } + + selectHeight(event) { + const { preset, height } = event.currentTarget.dataset; + const section = this._section(); + if (section && height) { + section.style.setProperty("--dash-widget-h", `${height}px`); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ height: preset }); + } + + selectWidth(event) { + const { colSpan } = event.currentTarget.dataset; + const section = this._section(); + if (section) { + section.classList.toggle("2xl:col-span-2", colSpan === "full"); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ col_span: colSpan }); + // Width change re-flows the grid; nudge dashboard-masonry to re-pack. + window.dispatchEvent(new Event("resize")); + } + + _section() { + return this.element.closest("[data-section-key]"); + } + + // Move the active state to the clicked segment within its segmented control, + // mirroring DS::SegmentedControl's class + aria-pressed contract. + _markSelected(button) { + const group = button.closest(".segmented-control"); + if (!group) return; + group.querySelectorAll(".segmented-control__segment").forEach((el) => { + const on = el === button; + el.classList.toggle("segmented-control__segment--active", on); + el.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + _closeOnOutsideClick(event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.open = false; + } + } + + async _save(payload) { + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error("[Dashboard Widget Size] CSRF token not found."); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ + preferences: { + dashboard_section_layout: { [this.sectionKeyValue]: payload }, + }, + }), + }); + + if (!response.ok) { + console.error( + "[Dashboard Widget Size] Failed to save layout:", + response.status, + ); + } + } catch (error) { + console.error( + "[Dashboard Widget Size] Network error saving layout:", + error, + ); + } + } +} diff --git a/app/models/user.rb b/app/models/user.rb index cf1c257c1..1955a64fb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -314,6 +314,16 @@ class User < ApplicationRecord preferences&.[]("section_order") || default_dashboard_section_order end + # Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default. + def dashboard_section_height(section_key) + preferences&.dig("dashboard_section_layout", section_key, "height") + end + + # Per-widget column-span override ("single" | "full"); nil = use default. + def dashboard_section_width(section_key) + preferences&.dig("dashboard_section_layout", section_key, "col_span") + end + def update_dashboard_preferences(prefs) # Use pessimistic locking to ensure atomic read-modify-write # This prevents race conditions when multiple sections are collapsed quickly @@ -324,7 +334,9 @@ class User < ApplicationRecord prefs.each do |key, value| if value.is_a?(Hash) updated_prefs[key] ||= {} - updated_prefs[key] = updated_prefs[key].merge(value) + # deep_merge so a partial update of one nested dimension (e.g. a widget's + # col_span) doesn't clobber a sibling dimension (e.g. its height). + updated_prefs[key] = updated_prefs[key].deep_merge(value) else updated_prefs[key] = value end diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index c2fd65fa4..c804f0c1b 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -36,12 +36,14 @@ <% end %> -
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> + <% two_column = Current.user.dashboard_two_column? %> +
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable dashboard-masonry" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="<%= t("pages.dashboard.sections_aria_label") %>"> <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %>
" + style="<%= "--dash-widget-h: #{section[:layout][:height_px]}px;" if section[:layout][:grow] %>" data-dashboard-sortable-target="section" data-section-key="<%= section[:key] %>" data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>" @@ -85,6 +87,57 @@ <%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %> <% end %> + <% layout = section[:layout] %> + <% show_width = layout[:width_toggle] && two_column %> + <% if layout[:grow] || show_width %> + + <% end %>