mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 05:24:57 +00:00
Add period navigation arrows to Reports view (#1756)
* Add period navigation arrows to reports view * Fix accessibility: render disabled next arrow as span instead of anchor * Add tests for period navigation arrows and localized strings * Refactor period navigation: move date logic to controller * Fix test assertions: tighten selectors and remove debug code * Redesign period navigation arrows to match budget screen style * custom period test assert next period * Add YTD tests and fix indentation in period navigation tests * Add period picker menu to reports navigation * Fix accessibility: use disabled button for next arrow * fix a test that was lost in the repos update * Use i18n for period navigation labels * Add accessible labels to period picker navigation links * Use i18n for quarter and YTD labels in period picker * Add accessible labels to active period navigation chevrons * Tighten custom period navigation test assertions * Add comment clarifying build_period_navigation dependency on setup_report_data * Replace link_to with DS::Link in period picker navigation Use Date#quarter instead of manual quarter calculation Remove border from month/quarter/year display in period picker
This commit is contained in:
@@ -88,6 +88,15 @@ class ReportsController < ApplicationController
|
||||
# It will render *inside* the modal frame.
|
||||
end
|
||||
|
||||
def picker
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@start_date = parse_date_param(:start_date) || Date.current.beginning_of_month
|
||||
render partial: "reports/period_picker", locals: {
|
||||
period_type: @period_type,
|
||||
start_date: @start_date
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def setup_report_data(show_flash: false)
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@@ -128,6 +137,9 @@ class ReportsController < ApplicationController
|
||||
|
||||
# Flags for view rendering
|
||||
@has_accounts = accessible_accounts.any?
|
||||
|
||||
# Build navigation links for period switching
|
||||
@nav = build_period_navigation
|
||||
end
|
||||
|
||||
def preferences_params
|
||||
@@ -1070,4 +1082,65 @@ class ReportsController < ApplicationController
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def build_period_navigation
|
||||
# Called at the end of setup_report_data, so @start_date and @end_date are guaranteed to be set.
|
||||
case @period_type
|
||||
when :monthly
|
||||
prev_start = @start_date.beginning_of_month - 1.month
|
||||
prev_end = prev_start.end_of_month
|
||||
next_start = @start_date.beginning_of_month + 1.month
|
||||
next_end = next_start.end_of_month
|
||||
at_latest = @start_date.beginning_of_month >= Date.current.beginning_of_month
|
||||
when :quarterly
|
||||
prev_start = (@start_date.beginning_of_quarter - 1.day).beginning_of_quarter
|
||||
prev_end = prev_start.end_of_quarter
|
||||
next_start = @end_date.end_of_quarter + 1.day
|
||||
next_end = next_start.end_of_quarter
|
||||
at_latest = @start_date.beginning_of_quarter >= Date.current.beginning_of_quarter
|
||||
when :ytd
|
||||
prev_year = @start_date.year - 1
|
||||
prev_start = Date.new(prev_year, 1, 1)
|
||||
prev_end = Date.new(prev_year, 12, 31)
|
||||
next_year = @start_date.year + 1
|
||||
next_start = Date.new(next_year, 1, 1)
|
||||
next_end = next_year == Date.current.year ? Date.current : Date.new(next_year, 12, 31)
|
||||
at_latest = @start_date.year >= Date.current.year
|
||||
when :last_6_months
|
||||
prev_start = @start_date.beginning_of_month - 6.months
|
||||
prev_end = prev_start + 6.months - 1.day
|
||||
candidate_start = @start_date.beginning_of_month + 6.months
|
||||
if candidate_start + 6.months >= Date.current.beginning_of_month
|
||||
next_end = Date.current.end_of_month
|
||||
next_start = (next_end + 1.day - 6.months).beginning_of_month
|
||||
else
|
||||
next_start = candidate_start
|
||||
next_end = next_start + 6.months - 1.day
|
||||
end
|
||||
at_latest = @end_date >= Date.current.end_of_month
|
||||
else
|
||||
return nil
|
||||
end
|
||||
|
||||
{ prev_start: prev_start, prev_end: prev_end, next_start: next_start, next_end: next_end, at_latest: at_latest, label: period_label }
|
||||
end
|
||||
|
||||
def period_label
|
||||
case @period_type
|
||||
when :monthly
|
||||
I18n.l(@start_date, format: :month_year)
|
||||
when :quarterly
|
||||
t("reports.index.period_label.quarterly", quarter: @start_date.quarter, year: @start_date.year)
|
||||
when :ytd
|
||||
if @start_date.year == Date.current.year
|
||||
t("reports.index.period_label.ytd", year: @start_date.year)
|
||||
else
|
||||
t("reports.index.period_label.past_year", year: @start_date.year)
|
||||
end
|
||||
when :last_6_months
|
||||
t("reports.index.period_label.last_6_months",
|
||||
start: I18n.l(@start_date, format: :short_month_year),
|
||||
end: I18n.l(@end_date, format: :short_month_year))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2 border border-tertiary rounded-md" data-budget-picker-target="year">
|
||||
<span class="w-40 text-center px-3 py-2">
|
||||
<%= year %>
|
||||
</span>
|
||||
|
||||
|
||||
150
app/views/reports/_period_picker.html.erb
Normal file
150
app/views/reports/_period_picker.html.erb
Normal file
@@ -0,0 +1,150 @@
|
||||
<%# locals: (period_type:, start_date:) %>
|
||||
|
||||
<%= turbo_frame_tag "reports_picker" do %>
|
||||
<div class="p-3 space-y-2">
|
||||
<% case period_type %>
|
||||
<% when :monthly %>
|
||||
<div class="flex items-center gap-2 justify-between mb-2">
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-left",
|
||||
href: picker_reports_path(period_type: :monthly, start_date: start_date.beginning_of_year - 1.year),
|
||||
aria: { label: t("reports.index.previous_year") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2">
|
||||
<%= start_date.year %>
|
||||
</span>
|
||||
|
||||
<% next_year_start = Date.new(start_date.year + 1, 1, 1) %>
|
||||
<% if next_year_start <= Date.current %>
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-right",
|
||||
href: picker_reports_path(period_type: :monthly, start_date: next_year_start),
|
||||
aria: { label: t("reports.index.next_year") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
<% else %>
|
||||
<span class="text-subdued inline-flex items-center justify-center w-9 h-9 rounded-lg">
|
||||
<%= icon "chevron-right", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 text-sm text-center font-medium">
|
||||
<% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, i| %>
|
||||
<% month = i + 1 %>
|
||||
<% date = Date.new(start_date.year, month, 1) %>
|
||||
<% if date <= Date.current %>
|
||||
<%= render DS::Link.new(
|
||||
variant: date.month == start_date.month ? "secondary" : "ghost",
|
||||
text: month_name,
|
||||
href: reports_path(period_type: :monthly, start_date: date, end_date: date.end_of_month),
|
||||
full_width: true,
|
||||
frame: :_top
|
||||
) %>
|
||||
<% else %>
|
||||
<span class="px-3 py-2 text-subdued rounded-md"><%= month_name %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% when :quarterly %>
|
||||
<div class="flex items-center gap-2 justify-between mb-2">
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-left",
|
||||
href: picker_reports_path(period_type: :quarterly, start_date: Date.new(start_date.year - 1, 1, 1)),
|
||||
aria: { label: t("reports.index.previous_year") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2">
|
||||
<%= start_date.year %>
|
||||
</span>
|
||||
|
||||
<% next_year_start = Date.new(start_date.year + 1, 1, 1) %>
|
||||
<% if next_year_start <= Date.current %>
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-right",
|
||||
href: picker_reports_path(period_type: :quarterly, start_date: next_year_start),
|
||||
aria: { label: t("reports.index.next_year") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
<% else %>
|
||||
<span class="text-subdued inline-flex items-center justify-center w-9 h-9 rounded-lg">
|
||||
<%= icon "chevron-right", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-center font-medium">
|
||||
<% (1..4).each do |q| %>
|
||||
<% quarter_start = Date.new(start_date.year, (q - 1) * 3 + 1, 1) %>
|
||||
<% quarter_end = quarter_start.end_of_quarter %>
|
||||
<% if quarter_start <= Date.current %>
|
||||
<%= render DS::Link.new(
|
||||
variant: quarter_start.quarter == start_date.quarter && start_date.year == quarter_start.year ? "secondary" : "ghost",
|
||||
text: t("reports.index.period_picker.quarter", quarter: q, year: start_date.year),
|
||||
href: reports_path(period_type: :quarterly, start_date: quarter_start, end_date: quarter_end),
|
||||
full_width: true,
|
||||
frame: :_top
|
||||
) %>
|
||||
<% else %>
|
||||
<span class="px-3 py-2 text-subdued rounded-md"><%= t("reports.index.period_picker.quarter", quarter: q, year: start_date.year) %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% when :ytd %>
|
||||
<% decade_start = (start_date.year / 10) * 10 %>
|
||||
<% decade_end = decade_start + 9 %>
|
||||
|
||||
<div class="flex items-center gap-2 justify-between mb-2">
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-left",
|
||||
href: picker_reports_path(period_type: :ytd, start_date: Date.new(decade_start - 1, 1, 1)),
|
||||
aria: { label: t("reports.index.previous_decade") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2">
|
||||
<%= decade_start %>–<%= decade_end %>
|
||||
</span>
|
||||
|
||||
<% if decade_start + 10 <= Date.current.year %>
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-right",
|
||||
href: picker_reports_path(period_type: :ytd, start_date: Date.new(decade_start + 10, 1, 1)),
|
||||
aria: { label: t("reports.index.next_decade") },
|
||||
frame: "reports_picker"
|
||||
) %>
|
||||
<% else %>
|
||||
<span class="text-subdued inline-flex items-center justify-center w-9 h-9 rounded-lg">
|
||||
<%= icon "chevron-right", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-center font-medium">
|
||||
<% (decade_start..decade_end).each do |year| %>
|
||||
<% next if year > Date.current.year %>
|
||||
<% year_start = Date.new(year, 1, 1) %>
|
||||
<% year_end = year == Date.current.year ? Date.current : Date.new(year, 12, 31) %>
|
||||
<%= render DS::Link.new(
|
||||
variant: year == start_date.year ? "secondary" : "ghost",
|
||||
text: year == Date.current.year ? t("reports.index.period_picker.ytd", year: year) : year.to_s,
|
||||
href: reports_path(period_type: :ytd, start_date: year_start, end_date: year_end),
|
||||
full_width: true,
|
||||
frame: :_top
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -90,11 +90,54 @@
|
||||
<% end %>
|
||||
|
||||
<%# Period Display %>
|
||||
<div class="text-sm text-subdued">
|
||||
<%= t("reports.index.showing_period",
|
||||
start: @start_date.strftime("%b %-d, %Y"),
|
||||
end: @end_date.strftime("%b %-d, %Y")) %>
|
||||
</div>
|
||||
<% if @nav %>
|
||||
<div class="flex items-center gap-1 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-left",
|
||||
href: reports_path(period_type: @period_type, start_date: @nav[:prev_start], end_date: @nav[:prev_end]),
|
||||
aria: { label: t("reports.index.previous_period") },
|
||||
) %>
|
||||
|
||||
<% if @nav[:at_latest] %>
|
||||
<button disabled aria-label="<%= t('reports.index.next_period') %>" class="text-subdued inline-flex items-center justify-center w-9 h-9 rounded-lg cursor-not-allowed">
|
||||
<%= icon("chevron-right", color: "current") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-right",
|
||||
href: reports_path(period_type: @period_type, start_date: @nav[:next_start], end_date: @nav[:next_end]),
|
||||
aria: { label: t("reports.index.next_period") },
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if [:monthly, :quarterly, :ytd].include?(@period_type) %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
|
||||
<span class="text-primary font-medium text-lg lg:text-base"><%= @nav[:label] %></span>
|
||||
<%= icon("chevron-down") %>
|
||||
<% end %>
|
||||
|
||||
<% menu.with_custom_content do %>
|
||||
<%= render "reports/period_picker", period_type: @period_type, start_date: @start_date %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-primary font-medium text-lg lg:text-base"><%= @nav[:label] %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.today"),
|
||||
variant: "outline",
|
||||
href: reports_path(period_type: @period_type),
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ en:
|
||||
default: "%Y-%m-%d"
|
||||
long: "%B %d, %Y"
|
||||
short: "%b %d"
|
||||
month_year: "%B %Y"
|
||||
short_month_year: "%b %Y"
|
||||
month_names:
|
||||
-
|
||||
- January
|
||||
|
||||
@@ -18,6 +18,21 @@ en:
|
||||
from: From
|
||||
to: To
|
||||
showing_period: "Showing data from %{start} to %{end}"
|
||||
previous_period: "Previous period"
|
||||
next_period: "Next period"
|
||||
today: "Today"
|
||||
period_label:
|
||||
quarterly: "Q%{quarter} %{year}"
|
||||
ytd: "YTD %{year}"
|
||||
past_year: "%{year}"
|
||||
last_6_months: "%{start} – %{end}"
|
||||
period_picker:
|
||||
quarter: "Q%{quarter} %{year}"
|
||||
ytd: "YTD %{year}"
|
||||
previous_year: "Previous year"
|
||||
next_year: "Next year"
|
||||
previous_decade: "Previous decade"
|
||||
next_decade: "Next decade"
|
||||
invalid_date_range: "End date cannot be before start date. The dates have been swapped."
|
||||
summary:
|
||||
total_income: Total Income
|
||||
|
||||
@@ -288,6 +288,7 @@ Rails.application.routes.draw do
|
||||
get :export_transactions, on: :collection
|
||||
get :google_sheets_instructions, on: :collection
|
||||
get :print, on: :collection
|
||||
get :picker, on: :collection
|
||||
end
|
||||
|
||||
resources :budgets, only: %i[index show edit update], param: :month_year do
|
||||
|
||||
@@ -107,12 +107,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
|
||||
)
|
||||
|
||||
assert_response :ok
|
||||
# Should show flash message about invalid date range
|
||||
assert flash[:alert].present?, "Flash alert should be present"
|
||||
assert_match /End date cannot be before start date/, flash[:alert]
|
||||
# Verify the response body contains the swapped date range in the correct order
|
||||
assert_includes @response.body, end_date.strftime("%b %-d, %Y")
|
||||
assert_includes @response.body, start_date.strftime("%b %-d, %Y")
|
||||
assert_equal I18n.t("reports.invalid_date_range"), flash[:alert]
|
||||
assert_includes @response.body, end_date.strftime("%b %Y")
|
||||
assert_includes @response.body, start_date.strftime("%b %Y")
|
||||
end
|
||||
|
||||
test "spending patterns returns data when expense transactions exist" do
|
||||
@@ -245,4 +242,99 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select "tr[data-category='category-#{subcategory_movies.id}']", text: /^Movies/
|
||||
assert_select "tr[data-category='category-#{subcategory_games.id}']", text: /^Games/
|
||||
end
|
||||
|
||||
test "monthly period navigation shows previous month link" do
|
||||
get reports_path(period_type: :monthly)
|
||||
assert_response :ok
|
||||
|
||||
prev_start = Date.current.beginning_of_month - 1.month
|
||||
prev_end = prev_start.end_of_month
|
||||
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: prev_start, end_date: prev_end)
|
||||
end
|
||||
|
||||
test "monthly period navigation disables next arrow on current month" do
|
||||
get reports_path(period_type: :monthly)
|
||||
assert_response :ok
|
||||
|
||||
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
|
||||
end
|
||||
|
||||
test "monthly period navigation shows next month link on past month" do
|
||||
past_start = Date.current.beginning_of_month - 2.months
|
||||
past_end = past_start.end_of_month
|
||||
get reports_path(period_type: :monthly, start_date: past_start, end_date: past_end)
|
||||
assert_response :ok
|
||||
|
||||
next_start = past_start + 1.month
|
||||
next_end = next_start.end_of_month
|
||||
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: next_start, end_date: next_end)
|
||||
end
|
||||
|
||||
test "last 6 months next window extends to current month end when crossing boundary" do
|
||||
start_date = Date.current.beginning_of_month - 12.months
|
||||
end_date = start_date + 6.months - 1.day
|
||||
|
||||
get reports_path(period_type: :last_6_months, start_date: start_date, end_date: end_date)
|
||||
assert_response :ok
|
||||
|
||||
candidate_start = start_date.beginning_of_month + 6.months
|
||||
if candidate_start + 6.months >= Date.current.beginning_of_month
|
||||
expected_next_end = Date.current.end_of_month
|
||||
expected_next_start = (expected_next_end + 1.day - 6.months).beginning_of_month
|
||||
else
|
||||
expected_next_start = candidate_start
|
||||
expected_next_end = expected_next_start + 6.months - 1.day
|
||||
end
|
||||
|
||||
assert_select "a[href=?]",
|
||||
reports_path(period_type: :last_6_months, start_date: expected_next_start, end_date: expected_next_end)
|
||||
end
|
||||
|
||||
test "quarterly period navigation shows previous and next quarter links" do
|
||||
get reports_path(period_type: :quarterly)
|
||||
assert_response :ok
|
||||
|
||||
prev_start = (Date.current.beginning_of_quarter - 1.day).beginning_of_quarter
|
||||
prev_end = prev_start.end_of_quarter
|
||||
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
|
||||
|
||||
# Also verify a past quarter shows an enabled next-quarter link
|
||||
get reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
|
||||
assert_response :ok
|
||||
|
||||
next_start = prev_start.next_quarter.beginning_of_quarter
|
||||
next_end = next_start.end_of_quarter
|
||||
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: next_start, end_date: next_end)
|
||||
end
|
||||
|
||||
test "custom period hides period display" do
|
||||
get reports_path(
|
||||
period_type: :custom,
|
||||
start_date: 1.month.ago.to_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
assert_response :ok
|
||||
|
||||
prev_start = 1.month.ago.to_date.beginning_of_month - 1.month
|
||||
next_start = 1.month.ago.to_date.beginning_of_month + 1.month
|
||||
assert_select "a[href*=?]", "start_date=#{prev_start}", count: 0
|
||||
assert_select "a[href*=?]", "start_date=#{next_start}", count: 0
|
||||
end
|
||||
|
||||
test "ytd period navigation shows previous year link" do
|
||||
get reports_path(period_type: :ytd)
|
||||
assert_response :ok
|
||||
|
||||
prev_year = Date.current.year - 1
|
||||
prev_start = Date.new(prev_year, 1, 1)
|
||||
prev_end = Date.new(prev_year, 12, 31)
|
||||
assert_select "a[href=?]", reports_path(period_type: :ytd, start_date: prev_start, end_date: prev_end)
|
||||
end
|
||||
|
||||
test "ytd period navigation disables next arrow on current year" do
|
||||
get reports_path(period_type: :ytd)
|
||||
assert_response :ok
|
||||
|
||||
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user