diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 3ec1e8dad..55c58a458 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -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 diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb index a1deaea03..47e2ba43f 100644 --- a/app/views/budgets/_picker.html.erb +++ b/app/views/budgets/_picker.html.erb @@ -15,7 +15,7 @@ <% end %> - + <%= year %> diff --git a/app/views/reports/_period_picker.html.erb b/app/views/reports/_period_picker.html.erb new file mode 100644 index 000000000..764c95616 --- /dev/null +++ b/app/views/reports/_period_picker.html.erb @@ -0,0 +1,150 @@ +<%# locals: (period_type:, start_date:) %> + +<%= turbo_frame_tag "reports_picker" do %> +
+ <% case period_type %> + <% when :monthly %> +
+ <%= 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" + ) %> + + + <%= start_date.year %> + + + <% 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 %> + + <%= icon "chevron-right", color: "current" %> + + <% end %> +
+ +
+ <% 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 %> + <%= month_name %> + <% end %> + <% end %> +
+ + <% when :quarterly %> +
+ <%= 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" + ) %> + + + <%= start_date.year %> + + + <% 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 %> + + <%= icon "chevron-right", color: "current" %> + + <% end %> +
+ +
+ <% (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 %> + <%= t("reports.index.period_picker.quarter", quarter: q, year: start_date.year) %> + <% end %> + <% end %> +
+ + <% when :ytd %> + <% decade_start = (start_date.year / 10) * 10 %> + <% decade_end = decade_start + 9 %> + +
+ <%= 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" + ) %> + + + <%= decade_start %>–<%= decade_end %> + + + <% 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 %> + + <%= icon "chevron-right", color: "current" %> + + <% end %> +
+ +
+ <% (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 %> +
+ <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index d3bd12b37..55ae1a8bb 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -90,11 +90,54 @@ <% end %> <%# Period Display %> -
- <%= t("reports.index.showing_period", - start: @start_date.strftime("%b %-d, %Y"), - end: @end_date.strftime("%b %-d, %Y")) %> -
+ <% if @nav %> +
+
+ <%= 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] %> + + <% 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 %> +
+ + <% 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 %> + <%= @nav[:label] %> + <%= icon("chevron-down") %> + <% end %> + + <% menu.with_custom_content do %> + <%= render "reports/period_picker", period_type: @period_type, start_date: @start_date %> + <% end %> + <% end %> + <% else %> + <%= @nav[:label] %> + <% end %> + +
+ <%= render DS::Link.new( + text: t("reports.index.today"), + variant: "outline", + href: reports_path(period_type: @period_type), + ) %> +
+
+ <% end %> <% end %> diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml index 2a3b5285a..609c459d9 100644 --- a/config/locales/defaults/en.yml +++ b/config/locales/defaults/en.yml @@ -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 diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml index 598070944..6ee05de94 100644 --- a/config/locales/views/reports/en.yml +++ b/config/locales/views/reports/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 36673f125..5f4aa337a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index c53472e99..298459d1f 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -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