Skip to content

Add drag-to-select date range on balance charts#2733

Open
Holdner wants to merge 11 commits into
we-promise:mainfrom
Holdner:feature/chart-drag-date-range-select
Open

Add drag-to-select date range on balance charts#2733
Holdner wants to merge 11 commits into
we-promise:mainfrom
Holdner:feature/chart-drag-date-range-select

Conversation

@Holdner

@Holdner Holdner commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Dragging across the dashboard net worth chart or an account's balance chart now selects the dragged date range (start_date/end_date), reusing the same Periodable query-param mechanism as the period picker — built on d3-brush (already vendored) and Turbo.visit, so both charts share one code path in time_series_chart_controller.js.
  • Fixes UI::PeriodPicker, which silently fell back to "30D" instead of showing "Custom" for a custom-range Period.
  • Opts the net worth chart out of the parent card's native HTML5 drag-and-drop (draggable="false") so dragging the chart selects a range instead of reordering dashboard cards.
  • Keeps drag-select mouse-only (touchable(false) on the brush) so native scrolling/panning is untouched on mobile.

Test plan

  • bin/rails test
  • DISABLE_PARALLELIZATION=true bin/rails test:system (new: AccountChartDragSelectTest, DashboardChartDragSelectTest)
  • bin/rubocop -f github -a
  • bundle exec erb_lint ./app/**/*.erb -a
  • npm run lint
  • bin/brakeman --no-pager
  • Manually verified: dragging navigates to the selected range on both charts, the net worth card no longer reorders on drag, the period picker shows "Custom" and can navigate back to any preset

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added drag-to-select date ranges on account balance and dashboard net worth charts, with interactive tooltip/brush behavior.
    • Period picker now supports custom ranges, including accurate “custom” labeling and navigation that uses the chosen start/end dates.
  • Bug Fixes
    • When both start_date and end_date are provided, the app prioritizes them for the selected period (invalid/reversed inputs fall back to the default range).
  • Tests
    • Expanded component, controller, and system test coverage for drag-select navigation, custom period UI, Turbo navigation behavior, and date-parameter precedence.

Holdner added 3 commits July 20, 2026 01:26
Dragging across the dashboard net worth chart or an account's balance chart now selects the dragged date range, reusing the same query-param mechanism as the period picker (start_date/end_date via Periodable).
Built on d3-brush (already vendored) and Turbo.visit, so both charts share one code path in time_series_chart_controller.js. Also fixes UI::PeriodPicker, which silently fell back to "30D" instead of showing "Custom" for a custom-range Period.
Drive the chart tooltip from d3-brush events and snap drag selections to the nearest plotted data points so tooltips and range navigation stay accurate. Opt the chart out of the parent card's HTML5 drag-and-drop by adding draggable="false" to the chart container and remove the turbo-frame visit option. Update assertions in PagesControllerTest and add a system test (DashboardChartDragSelectTest) that verifies dragging the net worth chart navigates to the selected date range instead of reordering dashboard cards.
Disable touch handling on d3-brush so native scrolling works on mobile devices. Drag-select is a mouse-only affordance.

Also extract custom period parsing into its own method with targeted error handling, so date range validation errors aren't confused with unrelated failures elsewhere in #set_period.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 217e5309-9e32-4a5d-b25a-103156d0b618

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd627c and 6195a15.

📒 Files selected for processing (6)
  • app/models/period.rb
  • config/locales/defaults/en.yml
  • config/locales/defaults/fr.yml
  • config/locales/models/period/en.yml
  • config/locales/models/period/fr.yml
  • test/models/period_test.rb

📝 Walkthrough

Walkthrough

Charts now support drag-selecting date ranges and navigating with start_date/end_date parameters. Custom periods are parsed and localized, period pickers preserve custom selections, and integration/system tests cover configuration, navigation, fallback behavior, and dashboard ordering.

Changes

Selectable chart flow

Layer / File(s) Summary
Custom period resolution and labeling
app/controllers/concerns/periodable.rb, app/models/period.rb, app/components/UI/period_picker.rb, config/locales/...
Explicit date ranges create validated custom periods, invalid ranges fall back to 30 days, and custom selections receive localized range labels and date-based links.
Period picker custom-option rendering
app/components/UI/period_picker.html.erb
Preset and custom links use Turbo advance navigation, with a checked custom option rendered for custom selections.
Chart brush interaction and wiring
app/javascript/controllers/time_series_chart_controller.js, app/components/UI/account/chart.html.erb, app/views/pages/dashboard/_net_worth_chart.html.erb
Selectable charts use brush overlays, render tooltips during selection, snap endpoints to plotted points, and navigate through Turbo.
Period, chart, and interaction validation
test/components/..., test/models/..., test/controllers/..., test/system/...
Tests cover custom labels, localization, fallback and precedence rules, selectable attributes, drag navigation, and preserved dashboard section order.
System test interaction support
test/application_system_test_case.rb
System login uses stable input selectors and shared drag gestures use rendered element dimensions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Chart
  participant Turbo
  participant Periodable
  User->>Chart: Drag across selectable chart
  Chart->>Chart: Snap endpoints to data points
  Chart->>Turbo: Visit URL with start_date and end_date
  Turbo->>Periodable: Request selected date range
  Periodable-->>User: Render custom period selection
Loading

Possibly related PRs

Suggested reviewers: jjmata

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary change: drag-to-select date ranges on balance charts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The mouse-drag gesture used fixed pixel offsets from the overlay's
center, assuming a specific rendered chart width. CI's headless Chrome
(Linux) renders the layout slightly differently than local runs,
shifting the overlay's actual width enough that the drag start point
landed outside it, so the brush never fired and no navigation happened.

Compute the offset and drag distance from the overlay's own measured
width instead, keeping a fixed inset from each edge so the gesture
always starts and ends within the overlay regardless of environment.

@jjmata jjmata left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for correctness only (no changes made).

Nicely scoped feature with good coverage — unit tests for Periodable/UI::PeriodPicker plus system tests for both chart drag paths. A few things I specifically checked and didn't find issues with:

  • Periodable#custom_period_from_params correctly falls back to last-30-days both for unparsable date strings and for a reversed range, via Period's own must_be_valid_date_range validation raising ActiveModel::ValidationError on validate!.
  • _nearestDataPointForPixel in time_series_chart_controller.js now clamps both bounds (Math.max(x0 - 1, 0) / Math.min(x0, length - 1)), fixing a latent out-of-bounds risk that existed in the old hover-only bisector code near the edges of the chart.
  • The draggable="false" on the net-worth chart container correctly overrides the ancestor <section draggable="true"> used for dashboard card reordering, and there's a system test asserting the drag-select gesture doesn't reorder cards.

No blocking concerns from my read.


Generated by Claude Code

@jjmata
jjmata requested a review from gariasf July 21, 2026 03:41

Copy link
Copy Markdown
Collaborator

This looks solid — CI is fully green, the feature is well-tested (new AccountChartDragSelectTest/DashboardChartDragSelectTest), and the flaky-test fix in the last commit shows good attention to CI stability. No blocking concerns found; mergeable_state: blocked appears to just be the required-review gate rather than a conflict.


Generated by Claude Code

Holdner added 4 commits July 24, 2026 18:59
Dragging across the dashboard net worth chart or an account's balance chart now selects the dragged date range, reusing the same query-param mechanism as the period picker (start_date/end_date via Periodable).
Built on d3-brush (already vendored) and Turbo.visit, so both charts share one code path in time_series_chart_controller.js. Also fixes UI::PeriodPicker, which silently fell back to "30D" instead of showing "Custom" for a custom-range Period.
Drive the chart tooltip from d3-brush events and snap drag selections to the nearest plotted data points so tooltips and range navigation stay accurate. Opt the chart out of the parent card's HTML5 drag-and-drop by adding draggable="false" to the chart container and remove the turbo-frame visit option. Update assertions in PagesControllerTest and add a system test (DashboardChartDragSelectTest) that verifies dragging the net worth chart navigates to the selected date range instead of reordering dashboard cards.
Disable touch handling on d3-brush so native scrolling works on mobile devices. Drag-select is a mouse-only affordance.

Also extract custom period parsing into its own method with targeted error handling, so date range validation errors aren't confused with unrelated failures elsewhere in #set_period.
The mouse-drag gesture used fixed pixel offsets from the overlay's
center, assuming a specific rendered chart width. CI's headless Chrome
(Linux) renders the layout slightly differently than local runs,
shifting the overlay's actual width enough that the drag start point
landed outside it, so the brush never fired and no navigation happened.

Compute the offset and drag distance from the overlay's own measured
width instead, keeping a fixed inset from each edge so the gesture
always starts and ends within the overlay regardless of environment.

@gariasf gariasf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature, and I like that both charts end up on one code path. Two things I'd want fixed before merge, both inline. I also verified a few claims that are easy to doubt, so noting them here so nobody re-checks:

  • d3-brush@3.0.0 really is available: pinned in config/importmap.rb and vendor/javascript/d3-brush.js exists, and vendor/javascript/d3.js re-exports it.
  • The .touchable(false) reasoning holds up in the vendored source — style("touch-action", "none") sits after .filter(touchable) in the same chain, so it genuinely isn't applied. Native scroll and touch card-reorder both stay intact.
  • A plain click is safe: d3 sets state.selection = null before emit.end(), so if (!event.selection) return catches it.
  • .move(null) from inside the end handler doesn't loop — the programmatic move has no sourceEvent (move handler bails) and re-emits end with a null selection (end handler bails).
  • d3 removes its window-level mousemove/mouseup listeners before emit.end(), so the Turbo.visit teardown can't leak them.
  • ReportsController also include Periodable and owns start_date/end_date with different semantics, but every action reassigns @period after the before_action, so nothing regresses there.
  • pagy passes request.query_parameters through and the activity list isn't period-filtered, so paging keeps the selected range. Correct as-is.
  • period.custom.label_short exists in every locale file, so the new "Custom" label isn't hardcoded English.

Two smaller things not worth inline comments:

  • _renderTooltipAt deref's this._d3Tooltip unguarded, so selectable: true + use_tooltip: false would TypeError on every mousedown. No caller hits it today (sparkline and onboarding are use_tooltip=false and non-selectable), so latent only — but _hideTooltip already uses ?., worth matching with an early if (!this._d3Tooltip) return;.
  • The two new system tests duplicate 14 lines of locale-agnostic sign-in plus an identical overlay-measuring drag block. The rationale for not using the shared sign_in is fair, but it belongs in application_system_test_case.rb next to it rather than copy-pasted twice.

Also: test/controllers/accounts_controller_test.rb "show accepts a custom start_date/end_date range" only asserts :success, so it passes even if the params are ignored entirely — an assert_select "button[aria-label='Time period: Custom']" would give it teeth. And start_date present with end_date absent is an untested branch.

Comment on lines +402 to +414
if (x1 - x0 < this._dragSelectMinPx) {
this._d3DragSelectGroup.call(this._d3DragSelectBrush.move, null);
return;
}

// Snap to the actual plotted data points (same as the tooltip shown
// during the drag) rather than the raw continuous pixel-to-time value —
// a few pixels of mouse imprecision can otherwise land on a date that's
// days away from the one the user was looking at on a wide chart.
const startDate = this._nearestDataPointForPixel(x0).date;
const endDate = this._nearestDataPointForPixel(x1).date;

this._navigateToDateRange(startDate, endDate);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard is in pixels, but what actually decides the outcome is the snapped dates three lines below it.

On a 30D chart at ~800px that's roughly 27px per day, so any drag between 4px and ~13px clears this threshold and then snaps both ends to the same datum, giving start_date == end_date. Period.custom accepts that — must_be_valid_date_range only rejects start > end — the series builder returns a single point, and _draw() takes the _normalDataPoints.length < 2 branch into _drawEmpty(). So a slightly-too-small drag blanks the chart and removes its brush and tooltip along with it; the only way back out is the period picker.

Guarding the real invariant instead makes the pixel threshold redundant, so _dragSelectMinPx on line 27 can go too:

Suggested change
if (x1 - x0 < this._dragSelectMinPx) {
this._d3DragSelectGroup.call(this._d3DragSelectBrush.move, null);
return;
}
// Snap to the actual plotted data points (same as the tooltip shown
// during the drag) rather than the raw continuous pixel-to-time value —
// a few pixels of mouse imprecision can otherwise land on a date that's
// days away from the one the user was looking at on a wide chart.
const startDate = this._nearestDataPointForPixel(x0).date;
const endDate = this._nearestDataPointForPixel(x1).date;
this._navigateToDateRange(startDate, endDate);
// Snap to the actual plotted data points (same as the tooltip shown
// during the drag) rather than the raw continuous pixel-to-time value —
// a few pixels of mouse imprecision can otherwise land on a date that's
// days away from the one the user was looking at on a wide chart.
const startDatum = this._nearestDataPointForPixel(x0);
const endDatum = this._nearestDataPointForPixel(x1);
// Reject *after* snapping: a short drag can resolve to a single data
// point on a wide chart, and a zero-day range renders as the empty-state
// chart — which has no brush to drag back out of.
if (startDatum === endDatum) {
this._d3DragSelectGroup.call(this._d3DragSelectBrush.move, null);
return;
}
this._navigateToDateRange(startDatum.date, endDatum.date);

Comment on lines +417 to +425
_navigateToDateRange(startDate, endDate) {
const formatDate = d3.timeFormat("%Y-%m-%d");
const url = new URL(window.location.href);

url.searchParams.delete("period");
url.searchParams.set("start_date", formatDate(startDate));
url.searchParams.set("end_date", formatDate(endDate));

Turbo.visit(url.toString());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the URL authoritative for the selected range, but on the dashboard the period picker can't clear it again.

dashboard.html.erb renders the picker with frame: "dashboard_sections", which becomes data-turbo-frame on each link. Turbo's getVisitAction finds no data-turbo-action on either the link or the frame, so the frame navigation never touches the address bar. Sequence:

  1. Drag-select → this Turbo.visit puts ?start_date=A&end_date=B in the URL.
  2. Click "30D" → the frame re-renders as 30D, address bar still A/B.
  3. Refresh, share, or bookmark → back to the custom range, because set_period gives start_date/end_date precedence over period.

So the user sees 30D, reloads, and silently gets the custom range back. Pre-PR this was invisible because the dashboard URL never carried period state at all.

Cleanest fix is in the picker, scoped to frame-mode callers so the account chart (which renders its picker outside the frame and already does a full visit) is unaffected — DS::MenuItem#merged_opts already merges caller data: with the frame attribute, so this composes:

<%# app/components/UI/period_picker.html.erb %>
  <% menu.with_item(
       variant: :link,
       text: period.label_short,
       href: href_for(period.key),
       frame: frame,
       selected: selected?(period.key),
       # Frame navigations don't touch the address bar, so a stale
       # ?start_date/&end_date from a chart drag-select would outlive a preset
       # pick and come back on refresh. Advance so the URL always matches
       # what's rendered.
       data: frame.present? ? { turbo_action: "advance" } : {}
     ) %>

The alternative, if you'd rather keep ?period= out of the dashboard URL, is to give this controller a frame value and navigate the frame on the dashboard — consistent either way, but then a drag-selected range is no longer shareable or refresh-safe, which is most of the point of putting it in the URL.

@gariasf

gariasf commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Follow-up: the picker can't represent the range it just selected

Testing the drag-select on a local build, the interaction itself works well. Where it falls down is the control that's supposed to reflect it. Three separate things, all in UI::PeriodPicker:

  1. The trigger reads a bare "Custom" — no dates. The range is on screen (shared/_trend_change renders "Jun 01, 2026 to Jun 15, 2026" next to the net-worth figure), but it's divorced from the control that owns it.
  2. The menu renders as a menuitemradio group with nothing checked. Verified on a real response: grep -c 'aria-checked="true"' → 0. A single-select list with zero selection reads as "nothing is applied", and it hides the fact that picking a preset is how you leave a custom range.
  3. No exit is signposted. Presets do clear it — except on the dashboard, where the frame-scoped picker never updates the address bar, so the stale start_date/end_date outlives the pick (the inline comment I left on _navigateToDateRange).

Before — trigger says "Custom", and there isn't a check mark anywhere in the list:

Period picker with a drag-selected range: trigger reads Custom, no menu item is checked

After — trigger carries the range, and a trailing checked Custom row anchors the selection:

Period picker showing Jun 01 to Jun 15 on the trigger and a checked Custom row at the end of the list

Same picker in dark mode

The turbo_action: "advance" bit is a prerequisite, not a drive-by

It's in the same patch on purpose. Without it, "pick a preset to clear it" is false on the dashboard: frame navigations don't touch the address bar, so the range comes back on refresh. The UX claim above doesn't hold until that's fixed.
than smuggling it in here.

Code suggestion

Full diff (git apply-able)
diff --git a/app/components/UI/period_picker.html.erb b/app/components/UI/period_picker.html.erb
index d7ccbf30..ce5c8fba 100644
--- a/app/components/UI/period_picker.html.erb
+++ b/app/components/UI/period_picker.html.erb
@@ -14,7 +14,22 @@
          text: period.label_short,
          href: href_for(period.key),
          frame: frame,
-         selected: selected?(period.key)
+         selected: selected?(period.key),
+         data: item_data
+       ) %>
+  <% end %>
+
+  <% if custom_selected? %>
+    <%# Last, so the presets keep their positions. Without this row a custom
+        range leaves the list with nothing checked, which reads as "no selection"
+        and hides the fact that picking a preset is how you leave it. %>
+    <% menu.with_item(
+         variant: :link,
+         text: custom_label,
+         href: custom_href,
+         frame: frame,
+         selected: true,
+         data: item_data
        ) %>
   <% end %>
 <% end %>
diff --git a/app/components/UI/period_picker.rb b/app/components/UI/period_picker.rb
index f0daffb0..5d1392d9 100644
--- a/app/components/UI/period_picker.rb
+++ b/app/components/UI/period_picker.rb
@@ -9,6 +9,10 @@ class UI::PeriodPicker < ApplicationComponent
   # The selected period is marked with a check icon and `aria-current`, and the
   # trigger button shows its label.
   #
+  # A custom range (no preset key — e.g. drag-selected on a chart) gets its own
+  # trailing menu row so the list always has exactly one checked item, and the
+  # trigger shows the range's actual dates rather than a bare "Custom".
+  #
   # NOTE: `url` must be a path without a query string; pass query state via
   # `extra_params` so the picker can compose `?period=…` cleanly.
   attr_reader :selected, :selected_key, :url, :frame, :extra_params, :placement
@@ -27,11 +31,25 @@ class UI::PeriodPicker < ApplicationComponent
   end
 
   def selected_label
-    # A Period object (e.g. a drag-selected custom range) knows its own label,
-    # including the nil-key "Custom" case — no need to round-trip through PERIODS.
+    # A custom range's own label is just "Custom", which says nothing about what
+    # the chart is showing. Show the dates instead — the same thing every other
+    # date-range control does when it isn't on a preset.
+    return selected.label_range if custom_selected?
+
+    # A Period object knows its own label — no need to round-trip through PERIODS.
     selected.respond_to?(:label_short) ? selected.label_short : period_for(selected_key).label_short
   end
 
+  # True when the selection is a Period with no preset key, i.e. an explicit
+  # date range rather than one of `PERIODS`.
+  def custom_selected?
+    selected_key.blank? && selected.respond_to?(:label_range)
+  end
+
+  def custom_label
+    t(".custom", default: "Custom")
+  end
+
   def selected?(key)
     key == selected_key
   end
@@ -40,6 +58,20 @@ class UI::PeriodPicker < ApplicationComponent
     "#{url}?#{extra_params.merge(period: key).to_query}"
   end
 
+  # Re-applies the range that's already active. Selecting it is a no-op by
+  # design — the row exists to *represent* the current selection in a
+  # single-select list, not to introduce a second way to set it.
+  def custom_href
+    "#{url}?#{extra_params.merge(start_date: selected.start_date, end_date: selected.end_date).to_query}"
+  end
+
+  # Frame-scoped navigations don't touch the address bar, so without this a
+  # stale ?start_date/&end_date (from a chart drag-select, which navigates the
+  # whole page) would outlive a preset pick and come back on refresh.
+  def item_data
+    frame.present? ? { turbo_action: "advance" } : {}
+  end
+
   private
     def period_for(key)
       Period.from_key(key)
diff --git a/app/models/period.rb b/app/models/period.rb
index b5a3c80b..77518f3c 100644
--- a/app/models/period.rb
+++ b/app/models/period.rb
@@ -205,6 +205,25 @@ class Period
     end
   end
 
+  # Compact rendering of the actual dates, sized for a control that has to show
+  # its own value (e.g. the period picker trigger). `label_short` answers "which
+  # option is this" and degrades to a bare "Custom" for keyless ranges, which
+  # tells the reader nothing; `comparison_label` is the long form shown beside
+  # trend values and is too wide for a button.
+  #
+  # The year is dropped while the whole range sits inside the current year — it
+  # adds width without disambiguating anything.
+  def label_range
+    return label_short if start_date.nil? || end_date.nil?
+
+    if start_date.year == end_date.year && end_date.year == Date.current.year
+      "#{I18n.l(start_date, format: :short)} – #{I18n.l(end_date, format: :short)}"
+    else
+      # Matches `comparison_label`'s format so the two read as the same range.
+      "#{start_date.strftime(@date_format)} – #{end_date.strftime(@date_format)}"
+    end
+  end
+
   private
     def key_metadata
       @key_metadata ||= PERIODS[key]
diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml
index a8a96ad6..fc4f5de7 100644
--- a/config/locales/views/components/en.yml
+++ b/config/locales/views/components/en.yml
@@ -3,6 +3,7 @@ en:
   UI:
     period_picker:
       aria_label: "Time period: %{period}"
+      custom: Custom
     account:
       activity_feed:
         toggle_selection_checkboxes: Toggle selection
diff --git a/test/components/UI/period_picker_test.rb b/test/components/UI/period_picker_test.rb
index 980cad1a..b3231364 100644
--- a/test/components/UI/period_picker_test.rb
+++ b/test/components/UI/period_picker_test.rb
@@ -56,13 +56,58 @@ class UI::PeriodPickerTest < ViewComponent::TestCase
     assert_equal 1, page.all("a[aria-checked='true']").size
   end
 
-  test "shows Custom for a custom (nil-key) Period without falling back to a preset label" do
+  test "trigger shows a custom range's dates rather than falling back to a preset label" do
     custom_period = Period.custom(start_date: 15.days.ago.to_date, end_date: Date.current)
 
     render_inline(UI::PeriodPicker.new(selected: custom_period, url: "/"))
 
-    assert_text "Custom"
-    # None of the preset period options should be marked as the active selection.
-    assert_equal 0, page.all("a[role='menuitemradio'][aria-checked='true']").size
+    assert_text custom_period.label_range
+    assert_selector "button[aria-label='Time period: #{custom_period.label_range}']"
+    # The bare word would tell the user nothing about what the chart is showing.
+    assert_no_selector "button[aria-label='Time period: Custom']"
+  end
+
+  test "a custom range adds a checked Custom row so the list still has one selection" do
+    custom_period = Period.custom(start_date: 15.days.ago.to_date, end_date: Date.current)
+
+    render_inline(UI::PeriodPicker.new(selected: custom_period, url: "/"))
+
+    links = page.all("a[role='menuitemradio']")
+    assert_equal Period.all.size + 1, links.size
+    assert_equal "Custom", links.last.text.strip
+
+    checked = page.all("a[role='menuitemradio'][aria-checked='true']")
+    assert_equal 1, checked.size
+    assert_equal "Custom", checked.first.text.strip
+    # Round-trips the active range, so selecting it re-applies rather than clears.
+    assert_match(/start_date=#{custom_period.start_date}/, checked.first[:href])
+    assert_match(/end_date=#{custom_period.end_date}/, checked.first[:href])
+  end
+
+  test "a preset selection renders no Custom row" do
+    render_inline(UI::PeriodPicker.new(selected: Period.last_30_days, url: "/"))
+
+    assert_equal Period.all.size, page.all("a[role='menuitemradio']").size
+    assert_no_text "Custom"
+  end
+
+  test "frame-scoped items advance the URL so a preset pick clears a stale custom range" do
+    render_inline(UI::PeriodPicker.new(
+      selected: Period.custom(start_date: 15.days.ago.to_date, end_date: Date.current),
+      url: "/",
+      frame: "dashboard_sections"
+    ))
+
+    links = page.all("a[role='menuitemradio']")
+    assert links.any?
+    links.each { |link| assert_equal "advance", link["data-turbo-action"] }
+  end
+
+  test "items without a target frame do not force a visit action" do
+    render_inline(UI::PeriodPicker.new(selected: Period.last_30_days, url: "/"))
+
+    page.all("a[role='menuitemradio']").each do |link|
+      assert_nil link["data-turbo-action"]
+    end
   end
 end
diff --git a/test/controllers/concerns/periodable_test.rb b/test/controllers/concerns/periodable_test.rb
index 0deaa83b..78410688 100644
--- a/test/controllers/concerns/periodable_test.rb
+++ b/test/controllers/concerns/periodable_test.rb
@@ -3,24 +3,30 @@ require "test_helper"
 class PeriodableTest < ActionDispatch::IntegrationTest
   setup do
     sign_in @user = users(:family_admin)
+
+    @start_date = 15.days.ago.to_date
+    @end_date = 5.days.ago.to_date
+    # The picker trigger shows the range's dates, not a bare "Custom", so derive
+    # the expected label instead of hardcoding a format that shifts by year.
+    @custom_label = Period.custom(start_date: @start_date, end_date: @end_date).label_range
   end
 
   test "start_date and end_date params build a custom period" do
-    get root_path, params: { start_date: "2026-06-01", end_date: "2026-06-15" }
+    get root_path, params: { start_date: @start_date.to_s, end_date: @end_date.to_s }
 
     assert_response :success
-    assert_select "button[aria-label='Time period: Custom']"
+    assert_select "button[aria-label='Time period: #{@custom_label}']"
   end
 
   test "invalid start_date or end_date falls back to last 30 days" do
-    get root_path, params: { start_date: "not-a-date", end_date: "2026-06-15" }
+    get root_path, params: { start_date: "not-a-date", end_date: @end_date.to_s }
 
     assert_response :success
     assert_select "button[aria-label='Time period: 30D']"
   end
 
   test "start_date and end_date range where start is after end falls back to last 30 days" do
-    get root_path, params: { start_date: "2026-06-15", end_date: "2026-06-01" }
+    get root_path, params: { start_date: @end_date.to_s, end_date: @start_date.to_s }
 
     assert_response :success
     assert_select "button[aria-label='Time period: 30D']"
@@ -29,15 +35,15 @@ class PeriodableTest < ActionDispatch::IntegrationTest
   test "start_date and end_date do not persist as the user's default period" do
     @user.update!(default_period: "last_7_days")
 
-    get root_path, params: { start_date: "2026-06-01", end_date: "2026-06-15" }
+    get root_path, params: { start_date: @start_date.to_s, end_date: @end_date.to_s }
 
     assert_equal "last_7_days", @user.reload.default_period
   end
 
   test "start_date and end_date take precedence over a period param" do
-    get root_path, params: { period: "last_7_days", start_date: "2026-06-01", end_date: "2026-06-15" }
+    get root_path, params: { period: "last_7_days", start_date: @start_date.to_s, end_date: @end_date.to_s }
 
     assert_response :success
-    assert_select "button[aria-label='Time period: Custom']"
+    assert_select "button[aria-label='Time period: #{@custom_label}']"
   end
 end
diff --git a/test/models/period_test.rb b/test/models/period_test.rb
index 33e01bb0..de067592 100644
--- a/test/models/period_test.rb
+++ b/test/models/period_test.rb
@@ -45,6 +45,21 @@ class PeriodTest < ActiveSupport::TestCase
     assert_equal "Custom Period", period.label
   end
 
+  test "label_range omits the year for a range inside the current year" do
+    period = Period.custom(
+      start_date: Date.new(Date.current.year, 6, 1),
+      end_date: Date.new(Date.current.year, 6, 15)
+    )
+
+    assert_equal "Jun 01 – Jun 15", period.label_range
+  end
+
+  test "label_range includes the year when the range leaves the current year" do
+    period = Period.custom(start_date: Date.new(2025, 12, 20), end_date: Date.new(2026, 1, 5))
+
+    assert_equal "Dec 20, 2025 – Jan 05, 2026", period.label_range
+  end
+
   test "all_time period can be created" do
     period = Period.from_key("all_time")
     assert_equal "all_time", period.key

Holdner added 2 commits July 25, 2026 12:59
Enhance the period picker to better display and handle custom date ranges (e.g., from chart drag-select):

- Show actual dates in picker trigger instead of generic "Custom"
- Add trailing menu row for custom ranges so list always has one checked item
- Add `label_range()` method to Period for compact date display
- Use `turbo_action="advance"` to update URL bar, preventing stale date params from persisting after preset selection
- Fix time series chart drag-select: improve small-drag detection, snap to nearest data point before validating range, handle missing tooltip gracefully
- Refactor system test helpers: make `sign_in()` i18n-safe by using field types, extract `drag_across()` helper for consistent chart testing
- Update all related tests and test data
@Holdner

Holdner commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @gariasf, this is an unusually thorough review, and every point landed. I re-derived each claim against the source before touching anything; all six hold. Two of them hold harder than stated, which changed the fix in ways worth flagging.


_handleDragSelectEnd — reject after snapping

Confirmed end to end. default_period defaults to last_30_days = 31 daily points, so a ~800px chart is ~27px per cell — the whole band between the 4px floor and one cell width collapses. Period.custom accepts start == end, generate_series(…, '1 day') UNION DISTINCT :end_date returns exactly one row, and _draw() takes the length < 2 branch into _drawEmpty(), which never calls _drawChart — so neither the brush nor the tooltip is reinstalled. Dead end, exactly as described.

Applied the datum-identity guard. One deviation: I kept _dragSelectMinPx. In the vendored d3-brush, ended() nulls the selection only when it's truly zero-width (empty() reduces to x0 === x1 for brushX), so a plain click is already absorbed by if (!event.selection) return — but 1–3px of jitter still arrives as a real selection. On the dense periods the identity guard can't catch (365D is ~2px per point; 5Y resolves to a weekly interval at ~3px), that jitter reliably straddles a midpoint and navigates to a two-point range the user never aimed at. The two guards cover different failure modes — jitter vs. collapse — and both now carry a comment saying which.

I didn't extend it to adjacent datums: a two-point range still takes the _drawChart path and stays escapable, and _drawXAxisLabels already handles the two-tick case explicitly.

turbo_action: "advance"

Confirmed — and applied unconditionally rather than gated on frame.present?, because the account page has the identical bug and the scoped version would have left it there.

UI::Account::Chart renders from app/components/UI/account_page.html.erb:7, inside turbo_frame_tag id at line 3 (id = dom_id(account, :container), no target:), and the picker sits above the inner chart_details frame — so its nearest <turbo-frame> is the container. Walking turbo-rails 2.0.16: #shouldInterceptNavigation reads data-turbo-frame || this.element.getAttribute("target")null → not _top → intercepted; #findFrameElementgetFrameElementById(null) ?? this.element → the container frame; getVisitAction(undefined, link, frame) finds no data-turbo-action anywhere → nullchangeHistory() is a no-op. Same sequence you traced for the dashboard: drag → /accounts/1?start_date=A&end_date=B, click 30D → frame swaps, address bar unchanged, refresh restores the range.

Unconditional is free outside a frame — getActionForLink is already getVisitAction(link) || "advance" — so a genuine Drive visit is unaffected. The component test now asserts data-turbo-action="advance" in both the framed and unframed cases.

Worth its own issue, out of scope here: every GET link and form inside turbo-frame#account_<id>_container navigates without updating the URL — including the chart_view auto-submit select right next to the picker.

Picker can't represent the range it just selected

Both sub-claims verified against the markup chain: selected: false on all 12 presets still makes them role="menuitemradio" (DS::MenuItem#selectable? is !selected.nil?), so it really is a radio group with zero checked members. Took the patch — Period#label_range, dates on the trigger, trailing checked custom row whose href round-trips the active range.

One correction to the diff — I dropped the new locale key. t(".custom") inside this component resolves to ui.period_picker.custom (ViewComponent stamps virtual_path = "ui/period_picker" and ActionView derives the scope from that), while the locale files nest the block under UI:. The key would never be read — which is also why sibling components use the absolute form, e.g. I18n.t("UI.account.chart.title.balance"). The row text is now selected.label_short, which resolves period.custom.label_short — already translated in every locale that ships period strings, so it's localized without a new key.

Same latent issue on period_picker.html.erb:5: t(".aria_label") is dead too, masked only because its inline default: happens to duplicate the en value. That's pre-existing and applies to the whole UI: block across config/locales/views/components/*.yml, so I left it for a separate pass rather than smuggling it in here.

Kept I18n.l(date, format: :short) as written — en.date.formats.short is "%b %d" in config/locales/defaults/en.yml, so "Jun 01 – Jun 15" is exactly right. Flagging so nobody "fixes" it later: Date::DATE_FORMATS[:short] is "%d %b", so to_fs(:short) would give "01 Jun" — the I18n.l spelling is the correct one.

One caveat I did not fix: the cross-year branch uses strftime(@date_format), so it's hardcoded English. That's inherited from comparison_label and I kept it deliberately so the trigger and the range printed above the chart read as the same string — but it is a real i18n gap that now surfaces in one more place.

_renderTooltipAt deref

Applied as ?. rather than an early return, to stay symmetric with _hideTooltip: the guideline, the two circles and the trendline split are drag feedback, not tooltip DOM, and _hideTooltip already tears all three down unconditionally while optional-chaining only the tooltip. An early return would have silently dropped drag feedback for a future selectable: true + use_tooltip: false chart and left _hideTooltip cleaning up things that were never drawn.

Added the same ?. to _d3DragSelectGroup in _handleDragSelectEnd while I was there — same shape, and reachable today: the ResizeObserver can fire _reinstall mid-drag, which nulls both _d3DragSelectGroup and _d3DragSelectBrush, and d3's window-level mouseup still dispatches into our end handler afterwards.

System test duplication

Agreed, and I fixed it at the root instead of adding a third sign-in variant: ApplicationSystemTestCase#sign_in now looks fields up by type and waits on find("h1", text: user.first_name) — every locale interpolates %{name} into pages.dashboard.welcome, so that keeps the body-level wait while being locale-independent. Plus a drag_across(element) helper carrying the overlay-width arithmetic from 53aab2b verbatim. Both drag tests are now just sign_in @user + drag_across find(…), about 14 lines lighter each, and the draggable="false" rationale stayed at the dashboard call site where it explains the call rather than the helper.

This turned out to be much bigger than a tidiness nit, and it's worth its own issue. Localize#switch_locale negotiates from Accept-Language, and locale_from_accept_language even persists the result to the user record. On a machine whose Chrome negotiates anything but English, the entire system suite fails — I measured it here: 84 of 86 errors before this change, 72 after (assertions executed went 6 → 55, i.e. tests get much further before dying on their own hardcoded English strings). Pin the browser to en-US and it's 86 runs, 0 failures, 0 errors.

So your original rationale for not using the shared helper was right, and it wasn't specific to those two tests — it's the whole suite. I deliberately did not pin the driver locale in this PR: it's shared infra beyond this change's scope, I can't exercise the dev-container/remote path from here, and pinning would mask exactly the brittleness you asked me to remove rather than fix it. Happy to open a follow-up for the remaining English-text lookups in the other 17 files if you agree that's the right split.

accounts_controller_test teeth + the untested branch

You're right that it passes with the params ignored. Rewrote it to assert on the selection rather than a label string, so it survives the picker relabel above:

assert_select "a[role='menuitemradio'][aria-checked='true'][href*='period=']", count: 0
assert_select "a[role='menuitemradio'][aria-checked='true'][href*='start_date=']", count: 1

It fails with count: 1 on period=last_30_days the moment Periodable stops honouring the params. I kept the request itself — it's the only coverage that a keyless Period renders account#show without raising.

The start_date-without-end_date branch went into periodable_test.rb instead, since it's a concern-level gap. It sets default_period: "last_7_days" first: family_admin inherits the last_30_days column default, which is byte-identical to the rescue Period::InvalidKeyError fallback, so a test that didn't change it couldn't tell the two paths apart. Also switched that file's custom-range assertions to derive the label from Period#label_range rather than hardcoding "Custom".


Two things I'd like your call on:

  1. The custom row is a link that re-applies the range it already represents, i.e. a no-op click. It fixes the empty radio group and shows the dates, but a non-actionable menuitem is arguable — a non-link aria-checked="true" row is the alternative.
  2. advance means each period pick adds a history entry, so Back returns to the drag-selected range. replace also satisfies changeHistory() if you'd rather the picker not accumulate history.

Checks: bin/rails test 5723 runs / 0 failures / 0 errors; DISABLE_PARALLELIZATION=true bin/rails test:system 86 runs / 0 failures / 0 errors (English-negotiating browser — see above); bin/rubocop, erb_lint, npm run lint and brakeman --no-pager all clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/period.rb`:
- Around line 222-224: Update both cross-year label_range and custom-period
comparison_label to use I18n date formatting/range keys instead of
strftime(`@date_format`), so month names follow the active locale while preserving
the displayed range format. Add a regression test using a non-English locale
covering both user-facing ranges.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a60b3af-332c-4719-99ee-5dd46c2e1138

📥 Commits

Reviewing files that changed from the base of the PR and between 53aab2b and 4fd627c.

📒 Files selected for processing (11)
  • app/components/UI/period_picker.html.erb
  • app/components/UI/period_picker.rb
  • app/javascript/controllers/time_series_chart_controller.js
  • app/models/period.rb
  • test/application_system_test_case.rb
  • test/components/UI/period_picker_test.rb
  • test/controllers/accounts_controller_test.rb
  • test/controllers/concerns/periodable_test.rb
  • test/models/period_test.rb
  • test/system/account_chart_drag_select_test.rb
  • test/system/dashboard_chart_drag_select_test.rb
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/controllers/accounts_controller_test.rb
  • test/controllers/concerns/periodable_test.rb
  • app/javascript/controllers/time_series_chart_controller.js

Comment thread app/models/period.rb Outdated
Remove hardcoded `date_format:` parameter and replace `strftime` calls with `I18n.l` to ensure date formatting respects the active locale's conventions (e.g., day-month-year vs month-day-year order, locale-specific month names, and translatable separators). Add new i18n keys for period ranges and custom comparison labels, plus `short_with_year` date format to English and French locales.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

4 participants