mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* Add "Money In / Out" dashboard widget Adds a new dashboard section showing a monthly bar chart of cash activity alongside a summary card (net balance, income, expenses), with per-widget month navigation and account filtering. - IncomeStatement#totals_for computes income/expense totals for an arbitrary period, optionally scoped to a set of account ids - New bar_chart_controller.js (D3) renders the monthly bars - Income/expense rows link through to filtered transactions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix tooltip/dropdown positioning and split money flow chart by income/expense The widget's @container wrapper established a new containing block for position:fixed descendants, so the month-picker and account-filter menus (floating-ui, strategy: fixed) and the D3 tooltip (container- relative coordinates) rendered away from their trigger/bar. Drop @container in favor of regular viewport breakpoints for this full-width widget, and position the tooltip with page-relative coordinates like the other chart controllers. Also replace the single combined bar per month with a grouped expense/income pair (red/green, with a legend) so each month's inflow and outflow are visible independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cap and scroll the money flow widget's month picker Add an optional max_height to DS::Menu (opt-in, backward compatible) so a long item list scrolls inside a fixed-height panel instead of overflowing the viewport. Use it for the money flow widget's 12-month picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use design system tokens for income/expense indicators in money flow widget Swap the four bg-green-500/bg-red-500 dot indicators (legend + income/expense row links) in the money flow widget for bg-success/bg-destructive, matching the "functional tokens only" rule. The same partial already uses text-success/text-destructive for the balance figure, and bg-success/ bg-destructive are already established elsewhere (DS::Alert, budget_categories/_budget_category.html.erb). * Fix future-month 500 and pending-transaction link mismatch in money flow widget Two CI review findings on the money flow widget: - A future month passed via ?money_flow_month= (e.g. a bookmarked/hand-edited URL) made end_date earlier than month_start once capped at Date.current, which Period.custom rejects, causing a 500. money_flow_month_param now clamps future months to the current month, same as it already does for malformed input. - The income/expense row links passed type/date/account filters but no status, so Transaction::Search included pending transactions even though the displayed totals (IncomeStatement#totals_for) exclude them via excluding_pending. Add status: ["confirmed"] to both links so the linked list matches the card total. Also strengthens the widget's controller tests: asserts the highlighted bar's actual income/expense values instead of just its presence, and adds a regression test proving an account id outside the current user's accessible accounts is dropped (falls back to the unfiltered state) rather than leaking or erroring. * Fix account filter eligibility and SVG dark mode fill in money flow widget Two more CI review findings on the money flow widget: The account filter iterated over all visible/accessible accounts, a broader set than IncomeStatement actually counts (accounts excluded from reports, tax-advantaged accounts like 401k/IRA, or shared accounts not included in the user's finances). Selecting one of these silently computed to zero while its drill-down link could still list its transactions. Add IncomeStatement#eligible_accounts, mirroring the same criteria already applied in the totals SQL, and use it for both the checkbox list and the account_ids intersection. The D3 axis tick text elements used text-primary/text-secondary, which set CSS color, not SVG fill, so labels rendered with the default black fill and were unreadable in dark mode. Add fill-current, matching the pattern already used in sankey_chart_controller.js. Adds controller/model tests for eligible_accounts (excluded from the account filter, ignored when passed as a filter id, excluded from totals) and verifies the dark-mode fill fix visually. * Extract duplicated bar-chart JSON parsing into a test helper The css_select("[data-controller='bar-chart']").first + JSON.parse(chart["data-bar-chart-data-value"]) pattern was repeated across four money flow widget tests in pages_controller_test.rb. Extract it into a private money_flow_bars helper and use it everywhere instead. * Preserve eligible account scope in money flow drill-down links when unfiltered The income/expense drill-down links used money_flow_data[:account_ids] directly, which is nil in the widget's default unfiltered state, so .compact dropped the account filter entirely from the link. TransactionsController treats an absent account_ids as all accessible accounts, a broader set than IncomeStatement#eligible_accounts (which excludes tax-advantaged, excluded-from-reports, and non-finance shared accounts). Users with any such account could click a displayed total and see transactions that were never counted in it. Use selected_account_ids (already computed as money_flow_data[:account_ids] || accounts.map(&:id)) for both links instead, so they always pin to the same eligible accounts backing the total, filtered or not. Left the month-picker link on money_flow_data[:account_ids] so navigating months while unfiltered doesn't bloat the URL with every eligible account id. Adds a regression test confirming the default (unfiltered) links include account_ids and exclude an ineligible account's id. * Refine Money In/Out dashboard widget - 6-month window (was 3), half-width default with responsive stack, income-first bars, 2px floor + faded in-progress (partial) month - Expense series/figures neutral (gray/text-primary) — app reserves red for negative/overspend; neutral zero balance - Account filter: outline DS::Button + list-filter icon trigger with in-panel search (DS::SearchInput + list-filter), matching the app's filter convention - i18n search_accounts (en + fr); bump money_flow bar-count test 3 -> 6 * Clarify Money In/Out scope: month label, 6-month caption, filter "All" Addresses confusion between the widget's own month picker and the dashboard's global period, and between the picked month and the 6-month chart span. - Card now headed with the selected month (e.g. "July 2026") so it's clear the totals below are that month's, driven by the picker - Chart legend row captioned "Last N months" so the trailing window is explicit - Info tooltip by the month picker: the widget scopes to the month you pick here, independent of the dashboard's top-level period - Account filter trigger reads "All accounts" when nothing is filtered out, instead of "Filter accounts (N)" - i18n (en + fr) for the new strings * Match tooltip expense dot to the gray bar palette --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com>
138 lines
7.4 KiB
Plaintext
138 lines
7.4 KiB
Plaintext
<%# locals: (money_flow_data:, accounts:, col_span: "single") %>
|
|
<%
|
|
pill_button_class = "inline-flex items-center gap-1.5 bg-container border border-secondary font-medium rounded-lg pl-3 pr-2 py-2 text-sm cursor-pointer text-primary hover:bg-container-inset-hover focus:outline-hidden focus:ring-0 whitespace-nowrap"
|
|
selected_account_ids = money_flow_data[:account_ids] || accounts.map { |a| a.id.to_s }
|
|
all_accounts_selected = selected_account_ids.size == accounts.size
|
|
period = money_flow_data[:period]
|
|
# At full width the chart and summary sit side by side; at single/half width
|
|
# they stack so the chart gets the full column and the month labels don't crowd.
|
|
stacked = col_span != "full"
|
|
%>
|
|
<div id="money-flow-section" class="px-4 space-y-4">
|
|
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
|
|
<p class="text-sm text-secondary">
|
|
<%= t(".date_range", start_date: l(period.date_range.begin, format: :long), end_date: l(period.date_range.end, format: :long)) %>
|
|
</p>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<%= render DS::Tooltip.new(text: t(".period_scope_hint"), placement: "top-start") %>
|
|
|
|
<%= render DS::Menu.new(variant: :button, placement: "bottom-end", max_height: "18rem") do |menu| %>
|
|
<% menu.with_button(type: "button", class: pill_button_class, aria: { label: t(".month_picker_aria_label") }) do %>
|
|
<span class="capitalize"><%= I18n.l(money_flow_data[:month], format: :month_year) %></span>
|
|
<%= icon("chevron-down", size: "sm") %>
|
|
<% end %>
|
|
|
|
<% (0..11).each do |i| %>
|
|
<% month = Date.current.beginning_of_month - i.months %>
|
|
<% menu.with_item(
|
|
variant: :link,
|
|
text: I18n.l(month, format: :month_year).capitalize,
|
|
href: root_path({ money_flow_month: month.iso8601, money_flow_account_ids: money_flow_data[:account_ids] }.compact),
|
|
frame: "dashboard_sections",
|
|
selected: month == money_flow_data[:month]
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<%= render DS::Popover.new(variant: :button, no_padding: true) do |popover| %>
|
|
<% popover.with_button(
|
|
type: "button",
|
|
text: all_accounts_selected ? t(".filter_accounts_all") : t(".filter_accounts", count: selected_account_ids.size),
|
|
variant: "outline",
|
|
icon: "list-filter"
|
|
) %>
|
|
|
|
<% popover.with_custom_content do %>
|
|
<%= form_with url: root_path, method: :get, data: { turbo_frame: "dashboard_sections" }, class: "p-3 w-56 space-y-2" do %>
|
|
<%= hidden_field_tag :money_flow_month, money_flow_data[:month].iso8601 %>
|
|
|
|
<div data-controller="list-filter">
|
|
<%= render DS::SearchInput.new(
|
|
variant: :embedded,
|
|
placeholder: t(".search_accounts"),
|
|
aria_label: t(".search_accounts"),
|
|
data: { list_filter_target: "input", action: "input->list-filter#filter" }
|
|
) %>
|
|
|
|
<div class="my-2 max-h-64 overflow-y-auto space-y-1" data-list-filter-target="list">
|
|
<% accounts.each do |account| %>
|
|
<label class="filterable-item flex items-center gap-2 p-1.5 rounded-md hover:bg-container-inset-hover cursor-pointer" data-filter-name="<%= account.name %>">
|
|
<%= check_box_tag "money_flow_account_ids[]", account.id, selected_account_ids.include?(account.id.to_s), id: nil, class: "checkbox checkbox--light" %>
|
|
<span class="text-sm text-primary truncate"><%= account.name %></span>
|
|
</label>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%= render DS::Button.new(type: "submit", text: t(".apply"), variant: "primary", full_width: true) %>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-6<%= " xl:flex-row" unless stacked %>">
|
|
<div class="w-full space-y-2<%= " xl:w-3/5" unless stacked %>">
|
|
<div class="flex items-center justify-between gap-4 px-1">
|
|
<div class="flex items-center gap-4">
|
|
<span class="flex items-center gap-1.5 text-xs text-secondary">
|
|
<span class="w-2 h-2 rounded-full bg-success"></span>
|
|
<%= t(".income") %>
|
|
</span>
|
|
<span class="flex items-center gap-1.5 text-xs text-secondary">
|
|
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
|
|
<%= t(".expenses") %>
|
|
</span>
|
|
</div>
|
|
<span class="text-xs text-secondary"><%= t(".chart_span", count: money_flow_data[:bars].size) %></span>
|
|
</div>
|
|
|
|
<div class="w-full h-44 privacy-sensitive"
|
|
data-controller="bar-chart"
|
|
data-bar-chart-data-value="<%= money_flow_data[:bars].to_json %>"
|
|
data-bar-chart-currency-value="<%= Current.family.currency %>"
|
|
data-bar-chart-income-label-value="<%= t(".income") %>"
|
|
data-bar-chart-expense-label-value="<%= t(".expenses") %>">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="w-full space-y-4<%= " xl:w-2/5" unless stacked %>">
|
|
<p class="text-sm font-medium text-primary capitalize"><%= I18n.l(money_flow_data[:month], format: :month_year) %></p>
|
|
|
|
<div class="flex items-center justify-between text-sm">
|
|
<span class="text-secondary"><%= t(".balance") %></span>
|
|
<span class="font-medium text-lg tabular-nums privacy-sensitive <%= money_flow_data[:balance].negative? ? "text-destructive" : (money_flow_data[:balance].zero? ? "text-primary" : "text-success") %>">
|
|
<%= format_money money_flow_data[:balance] %>
|
|
</span>
|
|
</div>
|
|
|
|
<%= link_to transactions_path(q: { types: [ "income" ], status: [ "confirmed" ], start_date: period.date_range.begin, end_date: period.date_range.end, account_ids: selected_account_ids }.compact),
|
|
class: "flex items-center justify-between p-3 border border-secondary rounded-lg hover:bg-container-inset-hover transition-colors group",
|
|
data: { turbo_frame: "_top" } do %>
|
|
<span class="flex items-center gap-2 text-sm text-primary">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-success"></span>
|
|
<%= t(".income") %>
|
|
</span>
|
|
<span class="flex items-center gap-2">
|
|
<span class="font-medium tabular-nums text-success privacy-sensitive"><%= format_money money_flow_data[:income] %></span>
|
|
<%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %>
|
|
</span>
|
|
<% end %>
|
|
|
|
<%= link_to transactions_path(q: { types: [ "expense" ], status: [ "confirmed" ], start_date: period.date_range.begin, end_date: period.date_range.end, account_ids: selected_account_ids }.compact),
|
|
class: "flex items-center justify-between p-3 border border-secondary rounded-lg hover:bg-container-inset-hover transition-colors group",
|
|
data: { turbo_frame: "_top" } do %>
|
|
<span class="flex items-center gap-2 text-sm text-primary">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-gray-400"></span>
|
|
<%= t(".expenses") %>
|
|
</span>
|
|
<span class="flex items-center gap-2">
|
|
<span class="font-medium tabular-nums text-primary privacy-sensitive"><%= format_money money_flow_data[:expense] %></span>
|
|
<%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|