feat(dashboard): masonry packing + per-widget size controls (#2328)

* feat(dashboard): masonry packing + per-widget size controls

In two-column mode the dashboard used a row-based CSS grid, so cards
stretched to equal row height and left dead space (e.g. the Net Worth
chart padded out to match the tall Balance Sheet table). Replace the
row-based layout with masonry packing and add per-widget size guardrails.

- Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven
  by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load).
  The DOM stays a single flat list, so drag/keyboard reorder is unaffected.
  Active only in multi-column mode; single column falls back to normal flow.
- Internal sizing: the net worth chart height is now driven by a
  --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads
  out below the chart.
- Guardrails: per-widget layout metadata (col_span, grow, min_height,
  width_toggle) in PagesController, with per-user overrides persisted under
  preferences["dashboard_section_layout"], deep-merged so width and height coexist.
- Size menu: a hover control on size-capable cards — Width (Half/Full) for the
  cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow
  widgets. The sankey defaults to full width.

Adds model + controller tests for preference persistence and i18n keys.

* refactor(dashboard): redesign size menu with segmented controls

The size menu used a plain radio list and a diagonal maximize-2 trigger that
collided with the cashflow sankey's modal-expand button. Replace it with a
layout-config popover: a sliders-horizontal trigger plus two labeled axis
groups (Width, Height), each rendered as a DS::SegmentedControl with the
active option filled. Clearer, more compact, and it reads as a card-layout
control. The widget-size controller now mirrors the segmented control's
active-class + aria-pressed contract.

* feat(dashboard): expose width toggle on balance sheet and investments

Tables benefit from horizontal room, so give Balance Sheet and Investments
the same Width (Half/Full) control as the sankey and net worth chart. Height
presets stay chart-only — a table sized to a fixed height would just add
whitespace or force scrolling. The Outflows donut is intentionally left out
(full width is mostly whitespace for a donut).

* fix(dashboard): address review feedback on size controls

- Only apply the full-width col-span and show the Width control when the
  two-column layout is enabled. A full widget previously leaked
  2xl:col-span-2 into the single-column grid, creating an implicit second
  column at 2xl widths and breaking the single-column preference. This also
  keeps the Width control coherent with the Appearance two-column setting
  (it now appears only where it does something). [Codex]
- Stop size-menu keydowns from bubbling to the section reorder handler, so
  keyboard users can open the menu and pick options without entering
  grab/reorder mode. [Codex]
- Harden preferences params: ignore a malformed (non-hash)
  dashboard_section_layout / collapsed_sections instead of raising a 500,
  and require section_order to be an array. [CodeRabbit]
- Localize the dashboard sections aria-label. [CodeRabbit]

* chore(settings): mention per-widget size controls in two-column copy

Surface the new per-widget width/height controls in the Appearance
"Two-column layout" description so the capability is discoverable.

* test(dashboard): assert non-mutation for malformed layout input

Addresses review feedback: asserting assert_nil made the test depend on
the fixture happening to have no dashboard height for net_worth_chart.
Capture the pre-PATCH value and assert it is unchanged, so the test
stays valid (malformed input ignored) even if the fixture later gets a
default height.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Guillem Arias Fauste
2026-06-15 20:34:34 +02:00
committed by GitHub
parent 08bfd43775
commit c29380ce57
10 changed files with 383 additions and 7 deletions

View File

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

View File

@@ -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 <section data-section-key> 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);
}
});
}
}

View File

@@ -0,0 +1,110 @@
import { Controller } from "@hotwired/stimulus";
// Per-widget layout picker (mounted on the <details> 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,
);
}
}
}

View File

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

View File

@@ -36,12 +36,14 @@
</div>
<% end %>
<div class="grid grid-cols-1 <%= "xl:grid-cols-2" if Current.user.dashboard_two_column? %> 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? %>
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2 2xl:items-start 2xl:gap-y-0 2xl:[grid-auto-flow:row_dense] 2xl:[grid-auto-rows:1px]" if 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] %>
<section
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2<%= " 2xl:col-span-2" if two_column && section[:layout][:col_span] == "full" %>"
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") %>
</button>
<% end %>
<% layout = section[:layout] %>
<% show_width = layout[:width_toggle] && two_column %>
<% if layout[:grow] || show_width %>
<details
class="relative hidden lg:block"
data-controller="dashboard-widget-size"
data-dashboard-widget-size-section-key-value="<%= section[:key] %>"
data-action="keydown->dashboard-widget-size#stopKeydown">
<summary
class="list-none cursor-pointer text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 [&::-webkit-details-marker]:hidden"
aria-label="<%= t("pages.dashboard.widget_size.label") %>">
<%= icon("sliders-horizontal", size: "sm", class: "!w-3.5 !h-3.5") %>
</summary>
<div class="absolute right-0 top-full mt-1.5 z-10 w-48 p-2.5 space-y-2.5 bg-surface rounded-xl shadow-border-xs border border-primary">
<% if show_width %>
<div class="space-y-1.5">
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
<%= icon("move-horizontal", size: "xs") %>
<%= t("pages.dashboard.widget_size.width_label") %>
</p>
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.width_label")) do |sc| %>
<% { "single" => "half", "full" => "full" }.each do |value, i18n_key| %>
<% sc.with_segment(
t("pages.dashboard.widget_size.#{i18n_key}"),
active: layout[:col_span] == value,
data: { action: "dashboard-widget-size#selectWidth", col_span: value }
) %>
<% end %>
<% end %>
</div>
<% end %>
<% if layout[:grow] %>
<div class="space-y-1.5">
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
<%= icon("move-vertical", size: "xs") %>
<%= t("pages.dashboard.widget_size.height_label") %>
</p>
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.height_label")) do |sc| %>
<% PagesController::DASHBOARD_HEIGHT_PRESETS.each do |preset, px| %>
<% sc.with_segment(
t("pages.dashboard.widget_size.#{preset}"),
active: layout[:height_preset] == preset,
data: { action: "dashboard-widget-size#selectHeight", preset: preset, height: px }
) %>
<% end %>
<% end %>
</div>
<% end %>
</div>
</details>
<% end %>
<button
type="button"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 hidden lg:block"

View File

@@ -18,7 +18,8 @@
<% if series.any? %>
<div
id="netWorthChart"
class="w-full flex-1 min-h-52 privacy-sensitive"
class="w-full min-h-52 privacy-sensitive"
style="height: var(--dash-widget-h, 13rem);"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>

View File

@@ -41,8 +41,18 @@ en:
welcome: "Welcome back, %{name}"
subtitle: "Here's what's happening with your finances"
new: "New"
sections_aria_label: "Dashboard sections"
drag_to_reorder: "Drag to reorder section"
toggle_section: "Toggle section visibility"
widget_size:
label: "Adjust size"
width_label: "Width"
half: "Half"
full: "Full"
height_label: "Height"
compact: "Compact"
auto: "Auto"
tall: "Tall"
net_worth_chart:
data_not_available: Data not available for the selected period
title: Net Worth

View File

@@ -112,7 +112,7 @@ en:
dashboard_title: Dashboard
dashboard_subtitle: Customize how the dashboard is displayed
dashboard_two_column_title: Two-column layout
dashboard_two_column_description: Display dashboard widgets in two columns on large screens. When off, widgets stack in a single column.
dashboard_two_column_description: Display dashboard widgets in two columns on large screens, with per-widget width and height controls in each widget's header. When off, widgets stack in a single column.
split_grouped_title: Group split transactions
split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows.
preferences:

View File

@@ -14,6 +14,35 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
assert_response :ok
end
test "update_preferences persists dashboard section layout height" do
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: { net_worth_chart: { height: "compact" } } }
}, as: :json
assert_response :ok
assert_equal "compact", @user.reload.dashboard_section_height("net_worth_chart")
end
test "update_preferences persists dashboard section width" do
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: { cashflow_sankey: { col_span: "single" } } }
}, as: :json
assert_response :ok
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
end
test "update_preferences ignores malformed dashboard_section_layout without erroring" do
previous_height = @user.reload.dashboard_section_height("net_worth_chart")
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: "not-a-hash" }
}, as: :json
assert_response :ok
assert_equal previous_height, @user.reload.dashboard_section_height("net_worth_chart")
end
test "dashboard memoizes income statement period totals while rendering" do
income_statement = IncomeStatement.new(@family)
IncomeStatement.stubs(:new).returns(income_statement)

View File

@@ -510,6 +510,45 @@ class UserTest < ActiveSupport::TestCase
assert_equal new_order, @user.dashboard_section_order
end
test "dashboard_section_height returns stored preset or nil" do
@user.update!(preferences: {})
assert_nil @user.dashboard_section_height("net_worth_chart")
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
})
@user.reload
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
assert_nil @user.dashboard_section_height("balance_sheet")
end
test "dashboard_section_width returns stored col_span or nil" do
@user.update!(preferences: {})
assert_nil @user.dashboard_section_width("cashflow_sankey")
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
})
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
end
test "dashboard_section_layout merges width and height without clobbering" do
@user.update!(preferences: {})
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
})
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
})
@user.reload
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
assert_equal "full", @user.dashboard_section_width("net_worth_chart")
end
test "handles empty preferences gracefully for dashboard methods" do
@user.update!(preferences: {})