diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index eaeb0a8b6..210dff919 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -16,9 +16,19 @@ class PagesController < ApplicationController @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) @outflows_data = build_outflows_donut_data(expense_totals) + @dashboard_sections = build_dashboard_sections + @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] end + def update_preferences + if Current.user.update_dashboard_preferences(preferences_params) + head :ok + else + head :unprocessable_entity + end + end + def changelog @release_notes = github_provider.fetch_latest_release_notes @@ -45,6 +55,64 @@ class PagesController < ApplicationController end private + 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] + end + end + + def build_dashboard_sections + all_sections = [ + { + key: "cashflow_sankey", + title: "pages.dashboard.cashflow_sankey.title", + partial: "pages/dashboard/cashflow_sankey", + locals: { sankey_data: @cashflow_sankey_data, period: @period }, + visible: Current.family.accounts.any?, + collapsible: true + }, + { + key: "outflows_donut", + title: "pages.dashboard.outflows_donut.title", + partial: "pages/dashboard/outflows_donut", + locals: { outflows_data: @outflows_data, period: @period }, + visible: Current.family.accounts.any? && @outflows_data[:categories].present?, + collapsible: true + }, + { + key: "net_worth_chart", + title: "pages.dashboard.net_worth_chart.title", + partial: "pages/dashboard/net_worth_chart", + locals: { balance_sheet: @balance_sheet, period: @period }, + visible: Current.family.accounts.any?, + collapsible: true + }, + { + key: "balance_sheet", + title: "pages.dashboard.balance_sheet.title", + partial: "pages/dashboard/balance_sheet", + locals: { balance_sheet: @balance_sheet }, + visible: Current.family.accounts.any?, + collapsible: true + } + ] + + # Order sections according to user preference + section_order = Current.user.dashboard_section_order + ordered_sections = section_order.map do |key| + all_sections.find { |s| s[:key] == key } + end.compact + + # Add any new sections that aren't in the saved order (future-proofing) + all_sections.each do |section| + ordered_sections << section unless ordered_sections.include?(section) + end + + ordered_sections + end + def github_provider Provider::Registry.get_provider(:github) end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 2365dd8eb..4addfb06e 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -37,9 +37,20 @@ class ReportsController < ApplicationController # Transactions breakdown @transactions = build_transactions_breakdown + # Build reports sections for collapsible/reorderable UI + @reports_sections = build_reports_sections + @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] end + def update_preferences + if Current.user.update_reports_preferences(preferences_params) + head :ok + else + head :unprocessable_entity + end + end + def export_transactions @period_type = params[:period_type]&.to_sym || :monthly @start_date = parse_date_param(:start_date) || default_start_date @@ -100,6 +111,52 @@ class ReportsController < ApplicationController end private + def preferences_params + prefs = params.require(:preferences) + {}.tap do |permitted| + permitted["reports_collapsed_sections"] = prefs[:reports_collapsed_sections].to_unsafe_h if prefs[:reports_collapsed_sections] + permitted["reports_section_order"] = prefs[:reports_section_order] if prefs[:reports_section_order] + end + end + + def build_reports_sections + all_sections = [ + { + key: "trends_insights", + title: "reports.trends.title", + partial: "reports/trends_insights", + locals: { trends_data: @trends_data, spending_patterns: @spending_patterns }, + visible: Current.family.transactions.any?, + collapsible: true + }, + { + key: "transactions_breakdown", + title: "reports.transactions_breakdown.title", + partial: "reports/transactions_breakdown", + locals: { + transactions: @transactions, + period_type: @period_type, + start_date: @start_date, + end_date: @end_date + }, + visible: Current.family.transactions.any?, + collapsible: true + } + ] + + # Order sections according to user preference + section_order = Current.user.reports_section_order + ordered_sections = section_order.map do |key| + all_sections.find { |s| s[:key] == key } + end.compact + + # Add any new sections that aren't in the saved order (future-proofing) + all_sections.each do |section| + ordered_sections << section unless ordered_sections.include?(section) + end + + ordered_sections + end def validate_and_fix_date_range(show_flash: false) return unless @start_date > @end_date diff --git a/app/javascript/controllers/dashboard_section_controller.js b/app/javascript/controllers/dashboard_section_controller.js new file mode 100644 index 000000000..6c7067ebe --- /dev/null +++ b/app/javascript/controllers/dashboard_section_controller.js @@ -0,0 +1,97 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["content", "chevron", "container", "button"]; + static values = { + sectionKey: String, + collapsed: Boolean, + }; + + connect() { + if (this.collapsedValue) { + this.collapse(false); + } + } + + toggle(event) { + event.preventDefault(); + if (this.collapsedValue) { + this.expand(); + } else { + this.collapse(); + } + } + + handleToggleKeydown(event) { + // Handle Enter and Space keys for keyboard accessibility + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); // Prevent section's keyboard handler from firing + this.toggle(event); + } + } + + collapse(persist = true) { + this.contentTarget.classList.add("hidden"); + this.chevronTarget.classList.add("rotate-180"); + this.collapsedValue = true; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "false"); + } + if (persist) { + this.savePreference(true); + } + } + + expand() { + this.contentTarget.classList.remove("hidden"); + this.chevronTarget.classList.remove("rotate-180"); + this.collapsedValue = false; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "true"); + } + this.savePreference(false); + } + + async savePreference(collapsed) { + const preferences = { + collapsed_sections: { + [this.sectionKeyValue]: collapsed, + }, + }; + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Dashboard Section] CSRF token not found. Cannot save preferences.", + ); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Dashboard Section] Failed to save preferences:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Dashboard Section] Network error saving preferences:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/dashboard_sortable_controller.js b/app/javascript/controllers/dashboard_sortable_controller.js new file mode 100644 index 000000000..d44d47c92 --- /dev/null +++ b/app/javascript/controllers/dashboard_sortable_controller.js @@ -0,0 +1,267 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["section"]; + + connect() { + this.draggedElement = null; + this.placeholder = null; + this.touchStartY = 0; + this.currentTouchY = 0; + this.isTouching = false; + this.keyboardGrabbedElement = null; + } + + // ===== Mouse Drag Events ===== + dragStart(event) { + this.draggedElement = event.currentTarget; + this.draggedElement.classList.add("opacity-50"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + event.dataTransfer.effectAllowed = "move"; + } + + dragEnd(event) { + event.currentTarget.classList.remove("opacity-50"); + event.currentTarget.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + } + + dragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(container.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + drop(event) { + event.preventDefault(); + event.stopPropagation(); + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.clearPlaceholders(); + this.saveOrder(); + } + + // ===== Touch Events ===== + touchStart(event) { + this.draggedElement = event.currentTarget; + this.touchStartY = event.touches[0].clientY; + this.isTouching = true; + this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + } + + touchMove(event) { + if (!this.isTouching || !this.draggedElement) return; + + event.preventDefault(); + this.currentTouchY = event.touches[0].clientY; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(this.element.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + touchEnd(event) { + if (!this.isTouching || !this.draggedElement) return; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + this.saveOrder(); + + this.isTouching = false; + this.draggedElement = null; + } + + // ===== Keyboard Navigation ===== + handleKeyDown(event) { + const currentSection = event.currentTarget; + + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveUp(currentSection); + } + break; + case "ArrowDown": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveDown(currentSection); + } + break; + case "Enter": + case " ": + event.preventDefault(); + this.toggleGrabMode(currentSection); + break; + case "Escape": + if (this.keyboardGrabbedElement) { + event.preventDefault(); + this.releaseKeyboardGrab(); + } + break; + } + } + + toggleGrabMode(section) { + if (this.keyboardGrabbedElement === section) { + this.releaseKeyboardGrab(); + } else { + this.grabWithKeyboard(section); + } + } + + grabWithKeyboard(section) { + // Release any previously grabbed element + if (this.keyboardGrabbedElement) { + this.releaseKeyboardGrab(); + } + + this.keyboardGrabbedElement = section; + section.setAttribute("aria-grabbed", "true"); + section.classList.add("ring-2", "ring-primary", "ring-offset-2"); + } + + releaseKeyboardGrab() { + if (this.keyboardGrabbedElement) { + this.keyboardGrabbedElement.setAttribute("aria-grabbed", "false"); + this.keyboardGrabbedElement.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + ); + this.keyboardGrabbedElement = null; + this.saveOrder(); + } + } + + moveUp(section) { + const previousSibling = section.previousElementSibling; + if (previousSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(section, previousSibling); + section.focus(); + } + } + + moveDown(section) { + const nextSibling = section.nextElementSibling; + if (nextSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(nextSibling, section); + section.focus(); + } + } + + getDragAfterElement(y) { + const draggableElements = [ + ...this.sectionTargets.filter((section) => section !== this.draggedElement), + ]; + + return draggableElements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY }, + ).element; + } + + showPlaceholder(element, position) { + if (!element) return; + + if (position === "before") { + element.classList.add("border-t-4", "border-primary"); + } else { + element.classList.add("border-b-4", "border-primary"); + } + } + + clearPlaceholders() { + this.sectionTargets.forEach((section) => { + section.classList.remove( + "border-t-4", + "border-b-4", + "border-primary", + "border-t-2", + "border-b-2", + ); + }); + } + + async saveOrder() { + const order = this.sectionTargets.map( + (section) => section.dataset.sectionKey, + ); + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Dashboard Sortable] CSRF token not found. Cannot save section order.", + ); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences: { section_order: order } }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Dashboard Sortable] Failed to save section order:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Dashboard Sortable] Network error saving section order:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/reports_section_controller.js b/app/javascript/controllers/reports_section_controller.js new file mode 100644 index 000000000..6ed08a8b7 --- /dev/null +++ b/app/javascript/controllers/reports_section_controller.js @@ -0,0 +1,97 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["content", "chevron", "button"]; + static values = { + sectionKey: String, + collapsed: Boolean, + }; + + connect() { + if (this.collapsedValue) { + this.collapse(false); + } + } + + toggle(event) { + event.preventDefault(); + if (this.collapsedValue) { + this.expand(); + } else { + this.collapse(); + } + } + + handleToggleKeydown(event) { + // Handle Enter and Space keys for keyboard accessibility + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); // Prevent section's keyboard handler from firing + this.toggle(event); + } + } + + collapse(persist = true) { + this.contentTarget.classList.add("hidden"); + this.chevronTarget.classList.add("rotate-180"); + this.collapsedValue = true; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "false"); + } + if (persist) { + this.savePreference(true); + } + } + + expand() { + this.contentTarget.classList.remove("hidden"); + this.chevronTarget.classList.remove("rotate-180"); + this.collapsedValue = false; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "true"); + } + this.savePreference(false); + } + + async savePreference(collapsed) { + const preferences = { + reports_collapsed_sections: { + [this.sectionKeyValue]: collapsed, + }, + }; + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Reports Section] CSRF token not found. Cannot save preferences.", + ); + return; + } + + try { + const response = await fetch("/reports/update_preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Reports Section] Failed to save preferences:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Reports Section] Network error saving preferences:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/reports_sortable_controller.js b/app/javascript/controllers/reports_sortable_controller.js new file mode 100644 index 000000000..82ddcc2f1 --- /dev/null +++ b/app/javascript/controllers/reports_sortable_controller.js @@ -0,0 +1,267 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["section"]; + + connect() { + this.draggedElement = null; + this.placeholder = null; + this.touchStartY = 0; + this.currentTouchY = 0; + this.isTouching = false; + this.keyboardGrabbedElement = null; + } + + // ===== Mouse Drag Events ===== + dragStart(event) { + this.draggedElement = event.currentTarget; + this.draggedElement.classList.add("opacity-50"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + event.dataTransfer.effectAllowed = "move"; + } + + dragEnd(event) { + event.currentTarget.classList.remove("opacity-50"); + event.currentTarget.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + } + + dragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(container.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + drop(event) { + event.preventDefault(); + event.stopPropagation(); + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.clearPlaceholders(); + this.saveOrder(); + } + + // ===== Touch Events ===== + touchStart(event) { + this.draggedElement = event.currentTarget; + this.touchStartY = event.touches[0].clientY; + this.isTouching = true; + this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + } + + touchMove(event) { + if (!this.isTouching || !this.draggedElement) return; + + event.preventDefault(); + this.currentTouchY = event.touches[0].clientY; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(this.element.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + touchEnd(event) { + if (!this.isTouching || !this.draggedElement) return; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + this.saveOrder(); + + this.isTouching = false; + this.draggedElement = null; + } + + // ===== Keyboard Navigation ===== + handleKeyDown(event) { + const currentSection = event.currentTarget; + + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveUp(currentSection); + } + break; + case "ArrowDown": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveDown(currentSection); + } + break; + case "Enter": + case " ": + event.preventDefault(); + this.toggleGrabMode(currentSection); + break; + case "Escape": + if (this.keyboardGrabbedElement) { + event.preventDefault(); + this.releaseKeyboardGrab(); + } + break; + } + } + + toggleGrabMode(section) { + if (this.keyboardGrabbedElement === section) { + this.releaseKeyboardGrab(); + } else { + this.grabWithKeyboard(section); + } + } + + grabWithKeyboard(section) { + // Release any previously grabbed element + if (this.keyboardGrabbedElement) { + this.releaseKeyboardGrab(); + } + + this.keyboardGrabbedElement = section; + section.setAttribute("aria-grabbed", "true"); + section.classList.add("ring-2", "ring-primary", "ring-offset-2"); + } + + releaseKeyboardGrab() { + if (this.keyboardGrabbedElement) { + this.keyboardGrabbedElement.setAttribute("aria-grabbed", "false"); + this.keyboardGrabbedElement.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + ); + this.keyboardGrabbedElement = null; + this.saveOrder(); + } + } + + moveUp(section) { + const previousSibling = section.previousElementSibling; + if (previousSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(section, previousSibling); + section.focus(); + } + } + + moveDown(section) { + const nextSibling = section.nextElementSibling; + if (nextSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(nextSibling, section); + section.focus(); + } + } + + getDragAfterElement(y) { + const draggableElements = [ + ...this.sectionTargets.filter((section) => section !== this.draggedElement), + ]; + + return draggableElements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY }, + ).element; + } + + showPlaceholder(element, position) { + if (!element) return; + + if (position === "before") { + element.classList.add("border-t-4", "border-primary"); + } else { + element.classList.add("border-b-4", "border-primary"); + } + } + + clearPlaceholders() { + this.sectionTargets.forEach((section) => { + section.classList.remove( + "border-t-4", + "border-b-4", + "border-primary", + "border-t-2", + "border-b-2", + ); + }); + } + + async saveOrder() { + const order = this.sectionTargets.map( + (section) => section.dataset.sectionKey, + ); + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Reports Sortable] CSRF token not found. Cannot save section order.", + ); + return; + } + + try { + const response = await fetch("/reports/update_preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences: { reports_section_order: order } }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Reports Sortable] Failed to save section order:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Reports Sortable] Network error saving section order:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 7c1a1a064..981e227b9 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -66,6 +66,18 @@ export default class extends Controller { } _draw() { + // Guard against invalid dimensions (e.g., when container is collapsed or not yet rendered) + const minWidth = 50; + const minHeight = 50; + + if ( + this._d3ContainerWidth < minWidth || + this._d3ContainerHeight < minHeight + ) { + // Skip rendering if dimensions are invalid + return; + } + if (this._normalDataPoints.length < 2) { this._drawEmpty(); } else { diff --git a/app/models/user.rb b/app/models/user.rb index b62ab8b61..57e57ac04 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -177,7 +177,71 @@ class User < ApplicationRecord AccountOrder.find(default_account_order) || AccountOrder.default end + # Dashboard preferences management + def dashboard_section_collapsed?(section_key) + preferences&.dig("collapsed_sections", section_key) == true + end + + def dashboard_section_order + preferences&.[]("section_order") || default_dashboard_section_order + 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 + transaction do + lock! # Acquire row-level lock (SELECT FOR UPDATE) + + updated_prefs = (preferences || {}).deep_dup + prefs.each do |key, value| + if value.is_a?(Hash) + updated_prefs[key] ||= {} + updated_prefs[key] = updated_prefs[key].merge(value) + else + updated_prefs[key] = value + end + end + + update!(preferences: updated_prefs) + end + end + + # Reports preferences management + def reports_section_collapsed?(section_key) + preferences&.dig("reports_collapsed_sections", section_key) == true + end + + def reports_section_order + preferences&.[]("reports_section_order") || default_reports_section_order + end + + def update_reports_preferences(prefs) + # Use pessimistic locking to ensure atomic read-modify-write + transaction do + lock! + + updated_prefs = (preferences || {}).deep_dup + prefs.each do |key, value| + if value.is_a?(Hash) + updated_prefs[key] ||= {} + updated_prefs[key] = updated_prefs[key].merge(value) + else + updated_prefs[key] = value + end + end + + update!(preferences: updated_prefs) + end + end + private + def default_dashboard_section_order + %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet] + end + + def default_reports_section_order + %w[trends_insights transactions_breakdown] + end def ensure_valid_profile_image return unless profile_image.attached? diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 34f3796cd..daf6454b4 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -27,34 +27,56 @@ <% end %> -
+
<% if Current.family.accounts.any? %> -
- <%= render partial: "pages/dashboard/cashflow_sankey", locals: { - sankey_data: @cashflow_sankey_data, - period: @period - } %> -
- - <% if @outflows_data[:categories].present? %> -
- <%= render partial: "pages/dashboard/outflows_donut", locals: { - outflows_data: @outflows_data, - period: @period - } %> + <% @dashboard_sections.each do |section| %> + <% next unless section[:visible] %> +
+
+
+ +

+ <%= t(section[:title]) %> +

+
+ +
+
+ <%= render partial: section[:partial], locals: section[:locals] %> +
<% end %> - -
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { - balance_sheet: @balance_sheet, - period: @period - } %> -
- -
- <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> -
<% else %>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index af9ab3165..5bacd50c5 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -1,8 +1,8 @@ <%# locals: (balance_sheet:, **args) %> -
+
<% balance_sheet.classification_groups.each do |classification_group| %> -
+

"> @@ -48,7 +48,7 @@

-
+
<% classification_group.account_groups.each_with_index do |account_group, idx| %>
diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index bb49fa50f..b47ca5739 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -1,10 +1,6 @@ <%# locals: (sankey_data:, period:) %>
-
-

- <%= t("pages.dashboard.cashflow_sankey.title") %> -

- +
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> <%= form.select :period, Period.as_options, diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index d368d3a54..2ac67416b 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -4,20 +4,14 @@ <% series = balance_sheet.net_worth_series(period: period) %>
-
-
-

<%= t(".title") %>

-
- - <% if series.trend.present? %> -

"> - <%= series.trend.current.format %> -

- <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> - <% else %> -

<%= t(".data_not_available") %>

- <% end %> -
+ <% if series.trend.present? %> +

"> + <%= series.trend.current.format %> +

+ <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> + <% else %> +

<%= t(".data_not_available") %>

+ <% end %>
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index b1132cf9b..c046a0a88 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -1,10 +1,6 @@ <%# locals: (outflows_data:, period:) %>
-
-

- <%= t("pages.dashboard.outflows_donut.title") %> -

- +
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> <%= form.select :period, Period.as_options, diff --git a/app/views/reports/_transactions_breakdown.html.erb b/app/views/reports/_transactions_breakdown.html.erb index 9cb4d26d8..fafb717e1 100644 --- a/app/views/reports/_transactions_breakdown.html.erb +++ b/app/views/reports/_transactions_breakdown.html.erb @@ -1,11 +1,4 @@
- <%# Header %> -
-

- <%= t("reports.transactions_breakdown.title") %> -

-
- <%# Export Controls %>
<% diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb index 84e05657d..1b3d83b53 100644 --- a/app/views/reports/_trends_insights.html.erb +++ b/app/views/reports/_trends_insights.html.erb @@ -1,9 +1,4 @@ -
-

- <%= t("reports.trends.title") %> -

- -
+
<%# Month-over-Month Trends %>

diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 13fd9d1fc..57cd109ba 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -86,7 +86,7 @@
<% if Current.family.transactions.any? %> - <%# Summary Dashboard %> + <%# Summary Dashboard - Always visible, not collapsible %>
<%= render partial: "reports/summary_dashboard", locals: { metrics: @summary_metrics, @@ -94,23 +94,57 @@ } %>
- <%# Trends & Insights %> -
- <%= render partial: "reports/trends_insights", locals: { - trends_data: @trends_data, - spending_patterns: @spending_patterns - } %> -
- - <%# Transactions Breakdown %> -
- <%= render partial: "reports/transactions_breakdown", locals: { - transactions: @transactions, - period_type: @period_type, - start_date: @start_date, - end_date: @end_date - } %> -
+ <%# Collapsible & Reorderable Sections %> +
+ <% @reports_sections.each do |section| %> + <% next unless section[:visible] %> +
+
+
+ +

+ <%= t(section[:title]) %> +

+
+ +
+
+ <%= render partial: section[:partial], locals: section[:locals] %> +
+
+ <% end %> +
<% else %> <%# Empty State %>
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 572b06c78..8fa38a416 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -7,6 +7,8 @@ en: welcome: "Welcome back, %{name}" subtitle: "Here's what's happening with your finances" new: "New" + drag_to_reorder: "Drag to reorder section" + toggle_section: "Toggle section visibility" net_worth_chart: data_not_available: Data not available for the selected period title: Net Worth @@ -15,6 +17,7 @@ en: no_account_subtitle: Since no accounts have been added, there's no data to display. Add your first accounts to start viewing dashboard data. no_account_title: No accounts yet balance_sheet: + title: "Balance Sheet" no_items: "No %{name} yet" add_accounts: "Add your %{name} accounts to see a full breakdown" cashflow_sankey: diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml index b09c5b97d..5b4ed7a49 100644 --- a/config/locales/views/reports/en.yml +++ b/config/locales/views/reports/en.yml @@ -5,6 +5,8 @@ en: title: Reports subtitle: Comprehensive insights into your financial health export: Export CSV + drag_to_reorder: "Drag to reorder section" + toggle_section: "Toggle section visibility" periods: monthly: Monthly quarterly: Quarterly diff --git a/config/routes.rb b/config/routes.rb index 8c82dc606..740277b35 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ Rails.application.routes.draw do get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" + patch "dashboard/preferences", to: "pages#update_preferences" resource :current_session, only: %i[update] @@ -104,6 +105,7 @@ Rails.application.routes.draw do end resources :reports, only: %i[index] do + patch :update_preferences, on: :collection get :export_transactions, on: :collection get :google_sheets_instructions, on: :collection end diff --git a/db/migrate/20251119122856_add_preferences_to_users.rb b/db/migrate/20251119122856_add_preferences_to_users.rb new file mode 100644 index 000000000..8af204480 --- /dev/null +++ b/db/migrate/20251119122856_add_preferences_to_users.rb @@ -0,0 +1,6 @@ +class AddPreferencesToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :preferences, :jsonb, default: {}, null: false + add_index :users, :preferences, using: :gin + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 16aa8cc72..c8d16889d 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -152,4 +152,128 @@ class UserTest < ActiveSupport::TestCase ensure Setting.openai_access_token = previous end + + test "update_dashboard_preferences handles concurrent updates atomically" do + @user.update!(preferences: {}) + + # Simulate concurrent updates from multiple requests + # Each thread collapses a different section simultaneously + threads = [] + sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet] + + sections.each_with_index do |section, index| + threads << Thread.new do + # Small staggered delays to increase chance of race conditions + sleep(index * 0.01) + + # Each thread loads its own instance and updates + user = User.find(@user.id) + user.update_dashboard_preferences({ + "collapsed_sections" => { section => true } + }) + end + end + + # Wait for all threads to complete + threads.each(&:join) + + # Verify all updates persisted (no data loss from race conditions) + @user.reload + sections.each do |section| + assert @user.dashboard_section_collapsed?(section), + "Expected #{section} to be collapsed, but it was not. " \ + "Preferences: #{@user.preferences.inspect}" + end + + # Verify all sections are in the preferences hash + assert_equal sections.sort, + @user.preferences.dig("collapsed_sections")&.keys&.sort, + "Expected all sections to be in preferences" + end + + test "update_dashboard_preferences merges nested hashes correctly" do + @user.update!(preferences: {}) + + # First update: collapse net_worth + @user.update_dashboard_preferences({ + "collapsed_sections" => { "net_worth_chart" => true } + }) + @user.reload + + assert @user.dashboard_section_collapsed?("net_worth_chart") + assert_not @user.dashboard_section_collapsed?("outflows_donut") + + # Second update: collapse outflows (should preserve net_worth) + @user.update_dashboard_preferences({ + "collapsed_sections" => { "outflows_donut" => true } + }) + @user.reload + + assert @user.dashboard_section_collapsed?("net_worth_chart"), + "First collapsed section should still be collapsed" + assert @user.dashboard_section_collapsed?("outflows_donut"), + "Second collapsed section should be collapsed" + end + + test "update_dashboard_preferences handles section_order updates" do + @user.update!(preferences: {}) + + # Set initial order + new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet] + @user.update_dashboard_preferences({ "section_order" => new_order }) + @user.reload + + assert_equal new_order, @user.dashboard_section_order + end + + test "handles empty preferences gracefully for dashboard methods" do + @user.update!(preferences: {}) + + # dashboard_section_collapsed? should return false when key is missing + assert_not @user.dashboard_section_collapsed?("net_worth_chart"), + "Should return false when collapsed_sections key is missing" + + # dashboard_section_order should return default order when key is missing + assert_equal %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet], + @user.dashboard_section_order, + "Should return default order when section_order key is missing" + + # update_dashboard_preferences should work with empty preferences + @user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] }) + @user.reload + + assert_equal %w[balance_sheet], @user.preferences["section_order"] + end + + test "handles empty preferences gracefully for reports methods" do + @user.update!(preferences: {}) + + # reports_section_collapsed? should return false when key is missing + assert_not @user.reports_section_collapsed?("trends_insights"), + "Should return false when reports_collapsed_sections key is missing" + + # reports_section_order should return default order when key is missing + assert_equal %w[trends_insights transactions_breakdown], + @user.reports_section_order, + "Should return default order when reports_section_order key is missing" + + # update_reports_preferences should work with empty preferences + @user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] }) + @user.reload + + assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"] + end + + test "handles missing nested keys in preferences for collapsed sections" do + @user.update!(preferences: { "section_order" => %w[cashflow] }) + + # Should return false when collapsed_sections key is missing entirely + assert_not @user.dashboard_section_collapsed?("net_worth_chart"), + "Should return false when collapsed_sections key is missing" + + # Should return false when section_key is missing from collapsed_sections + @user.update!(preferences: { "collapsed_sections" => {} }) + assert_not @user.dashboard_section_collapsed?("net_worth_chart"), + "Should return false when section key is missing from collapsed_sections" + end end