Skip to content

feat(frontend): read a chart day over its bar, and expand the zone rail on hover - #2441

Merged
dzarlax merged 4 commits into
constructorfabric:mainfrom
dzarlax:feat/rail-and-chart-hover
Aug 11, 2026
Merged

feat(frontend): read a chart day over its bar, and expand the zone rail on hover#2441
dzarlax merged 4 commits into
constructorfabric:mainfrom
dzarlax:feat/rail-and-chart-hover

Conversation

@dzarlax

@dzarlax dzarlax commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Two interaction changes on the person surface. Both came from watching someone use it rather than from reading the code.

Reading a day out of a chart

The value appeared in the caption under the strip. That is a change in the middle of a chart, which is exactly where a reader looking at one bar is not looking: they hover, the number moves somewhere in their periphery, and they never learn it was there.

It now sits over the bar the pointer is on, and the caption stops changing at all — what is down there is constant again.

One positioned element rather than a tooltip per day: a month is thirty-one triggers, and thirty-one floating cards is both heavy and the wrong shape. It sits above the bars, with headroom reserved, so it covers neither the neighbours being compared against nor the header's own comparisons — which are exactly what a reader is hovering in order to reason against.

It anchors to its bar's edge and grows inwards rather than centring. Centring needs the rendered width in order to stay inside the strip, and it does not have it: the text runs from "3 messages" to "3.5 hours of 8", so any constant guarding the ends is a guess that the longer strings walk straight past. Growing inwards cannot overflow whatever the text says.

The zone rail expands on hover

Ported from the lite product's rail. Four things make it work, and each is there because leaving it out broke something.

It opens on a timer, and that is the whole guard against opening over something a pointer was only passing. The first version delayed the fade in CSS instead and let the panel become clickable at once — which achieved the opposite, and is described under "what the first attempt got wrong" below.

The rail keeps its slot in the layout and the labels open OVER what is beside them: widening the element itself shoves the pane sideways every time a pointer crosses the rail on its way elsewhere. The buttons widen only while it is open — a label you can read but not click is a trap, because the pointer leaves the narrow column on its way to the word and the rail shuts before it arrives. People aim at what they can read. The buttons sit inside the hover target, so a pointer resting on one keeps it open rather than fighting what opened it. And a click collapses it until the pointer leaves: a click navigates, the pointer has not moved, and without this the rail reopens on top of the pane the click was aimed at.

Two things could not be copied over. The lite product keeps the post-click flag in sessionStorage because its click reloads the page; here navigation is client-side, so plain state carries it. And the open state is held in React rather than expressed as CSS variants — it is the product of two facts, one of which no selector can see. The variant version silently never matched and nothing said so, which is why the behaviour is now covered by tests rather than by eye.

What the first attempt got wrong

The panel takes pointer events while open. With it inert, the gaps between buttons belong to whatever is underneath, so a pointer moving from an icon towards its label crossed bare panel, the rail counted that as having been left, and it slammed shut under the hand reaching for it.

Fixing that introduced the opposite fault, which review caught. pointer-events is not animatable, so it flipped the instant the state did while the opacity waited out its delay: for that window the panel was an invisible, fully clickable band over the pane. Clicks aimed at a row landed on it and vanished, and a pointer merely crossing the rail entered it, counted as still being inside, and got the rail opened over the row it was heading for — the exact thing the delay was written to prevent. Hence the timer: with it, open and visible are the same fact and no such window exists.

Three further ways the state could be stranded, all found in review: a keyboard activation set the "stay shut" flag that only a departing pointer could clear, so Enter left the rail dead to the mouse; a width change unmounted the rail under the pointer without a leave event, so it returned already open; and focus never opened it at all, leaving a sighted keyboard user with eight identical icons. Each is covered by a test now, and two of those tests fail on the commit before the fix.

It deliberately does not cover the pane beside it, and what it does not cover is faded rather than dimmed. Dimming was tried first and did nothing: the rows do not object to being darkened, they object to being CUT, and a hard edge through the middle of a word reads as a rendering fault whatever its brightness.

Checks

tsc -b clean, eslint clean, 1081 unit tests pass, including eight covering the rail's open state. Verified in a browser: the click-then-still-hovering case, both ends of the chart strip measured against the strip and the card, and the labels measured against the box that used to clip them.

Summary by CodeRabbit

  • New Features
    • Lens navigation rail now expands on hover or keyboard focus, with smooth dismissal and responsive behavior.
    • Expanded navigation displays wider zone buttons and labels while preserving access to settings.
    • Day activity strips show hovered readings in a dedicated readout above each bar.
  • Bug Fixes
    • Improved pointer and touch handling prevents accidental expansion during brief crossings.
    • Navigation state now resets reliably when the layout or rail is remounted.
    • Day captions remain consistent while inspecting individual readings.

Alexey Panfilov added 2 commits August 11, 2026 18:16
The value appeared in the caption under the strip. That is a change in the
middle of a chart, which is exactly where a reader looking at one bar is not
looking: they hover, the number moves somewhere in their periphery, and they
never learn it was there.

It now sits over the bar the pointer is on, and the caption stops changing at
all — what is down there is constant again.

One positioned element rather than a tooltip per day: a month is thirty-one
triggers, and thirty-one floating cards is both heavy and the wrong shape. It
sits above the bars so it never covers the neighbours being compared against,
and its position is clamped so the first and last days do not push it outside
the card and get it clipped.

Refs constructorfabric#2408

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
…product

Four things make it work, and each is there because leaving it out broke
something.

The rail keeps its 56px slot and the labels open OVER what is beside them:
widening the element itself shoves the pane sideways every time a pointer
crosses the rail on its way elsewhere. The buttons widen only while it is open
— a label you can read but not click is a trap, because the pointer leaves the
narrow column on its way to the word and the rail shuts before it arrives.
People aim at what they can read. The buttons sit inside the hover target, so a
pointer resting on one keeps it open rather than fighting what opened it. And a
click collapses it until the pointer leaves: a click navigates, the pointer has
not moved, and without this the rail reopens on top of the pane the click was
aimed at.

Two things could not be copied. The lite product keeps the post-click flag in
sessionStorage because its click reloads the page; here navigation is
client-side, so plain state carries it. And the open state is held in React
rather than expressed as CSS variants — it is the product of two facts, one of
which no selector can see. The variant version silently never matched, and
nothing said so; hence the tests.

The panel takes pointer events while open. With it inert the gaps between
buttons belong to whatever is underneath, so a pointer moving from an icon
towards its label crossed bare panel, the rail counted that as having been
left, and it slammed shut under the hand reaching for it.

It deliberately does not cover the pane beside it. What the panel does not
cover is faded rather than dimmed: the rows do not object to being darkened,
they object to being CUT, and a hard edge through the middle of a word reads as
a rendering fault whatever its brightness.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@dzarlax
dzarlax requested a review from a team as a code owner August 11, 2026 16:20
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

LensRail now supports delayed hover expansion, keyboard and pointer interactions, responsive reset behavior, and expanded zone labels. LensRail tests cover these interactions. DayStrip now renders hovered readings above bars while retaining its summary caption.

Changes

Portal and metric interactions

Layer / File(s) Summary
Interactive LensRail behavior
src/frontend/src/components/portal/lens-rail.tsx
LensRail manages delayed expansion, dismissal, keyboard focus, touch handling, layout resets, pane fading, and expanded zone controls.
LensRail interaction validation
src/frontend/src/components/portal/lens-rail.test.tsx
Tests cover hover, click, pointer movement, phone layouts, keyboard navigation, remounts, timers, and brief pointer crossings.
DayStrip hover readout
src/frontend/src/components/widgets/metric-views/metric-activity.tsx
Hovered readings appear above the active bar, while the center caption continues to show the denominator or silent-day summary.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PointerUser
  participant LensRail
  participant ZoneItem
  participant ZoneNavigation
  PointerUser->>LensRail: pointer enters rail
  LensRail->>LensRail: start delayed expansion timer
  LensRail->>ZoneItem: render expanded labels
  PointerUser->>ZoneItem: click zone
  ZoneItem->>ZoneNavigation: select zone
  LensRail->>LensRail: dismiss after click
Loading

Possibly related PRs

Suggested reviewers: hello1101n

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both primary changes: chart day readouts and hover expansion of the zone rail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

…g its header

Review found ten things. The two that mattered both showed the previous design
claiming, in its own comments, to prevent something it did not.

The panel became clickable the moment `open` flipped while its opacity waited
200ms, because `pointer-events` is not animatable and carried no delay. For
that window it was an invisible 136px-wide sink over the pane: clicks aimed at
a row landed on it and vanished. Worse, a pointer crossing the rail entered the
invisible panel, which counted as still being inside, so the rail opened after
the delay anyway — over the row being reached for. The comment said the delay
stopped exactly that.

So the wait is now a timer and `open` means "the wait is over". Open and
visible are the same fact, and there is no window where one is true and the
other is not.

The `dismissed` guard was set by any click, including the one Enter produces on
a focused button — and it was only ever cleared by a pointer leaving. A
keyboard user pressing Enter left the rail dead to the mouse until they moved
onto it and off again. It is now set only for pointer-driven clicks, and blur
clears it too.

Three more ways the state was stranded: a width change unmounting the rail
under the pointer, focus never opening it at all (eight identical icons and the
label at zero opacity for anyone not using a pointer), and no reset on
unmount. The width case is handled during render rather than in an effect,
because an effect sets state after painting the stale frame.

`[&>div]:overflow-visible` had beaten `SidebarContent`'s own `overflow-auto` on
specificity and left the zone list unable to scroll at all. The escape now
applies to that one box and only while the labels are showing — a box cannot
clip one axis and release the other, so scrolling is the half worth losing, and
only for as long as something is being read.

The fade is `absolute` rather than `fixed` (it worked only because the rail
happens to sit at the viewport edge), starts from the panel's own token rather
than the page background (they differ, most visibly in dark theme, putting a
step at the seam), mirrors in RTL (a logical inset with a physical gradient put
the opaque end at the wrong side and reinstated the hard cut it exists to
remove), and renders only when the pane is actually beside the rail rather than
collapsed off-canvas.

On the chart: the readout stood 26px above a strip 8px below its header, so it
covered the comparisons a reader hovers in order to reason against. It now has
headroom. And it is anchored to its bar's edge and grown inwards rather than
centred and clamped — centring needs the rendered width to stay inside, and a
constant guarding the ends is a guess that longer strings walk past.

The tests mocked a layout value the app never produces, so the middle width
tier — where the pane is collapsed — went untested. Four cases added for the
orderings above; two of them fail on the previous commit.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@dzarlax

dzarlax commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a review round. The body above has been updated to describe what the code now does rather than what the first attempt did — the two differed materially after this, and a description of a design that is no longer there is worse than none.

The mechanism changed. The rail opened via a CSS delay on the fade; it now opens on a timer. pointer-events is not animatable, so it flipped the instant the state did while the opacity waited out its delay — leaving an invisible, fully clickable band over the pane for that window. Clicks aimed at a row landed on it and disappeared, and a pointer merely crossing the rail entered it, counted as still being inside, and got the rail opened over the row it was heading for. That is precisely what the delay had been written to prevent, and the comment beside it said so. With a timer, open and visible are the same fact and the window does not exist.

Three ways the state could be stranded, none of which types or the original four tests would show:

  • A keyboard activation set the "stay shut until the pointer leaves" flag. There is no pointer in that story, and nothing else cleared the flag, so pressing Enter left the rail dead to the mouse until the reader happened to move onto it and off again.
  • A width change unmounted the rail under the pointer without a leave event, so it came back already open with the pointer nowhere near it.
  • Focus never opened it, so a sighted keyboard user navigated eight identical icons with the labels at zero opacity. Screen readers were fine — the text survives opacity: 0 — which is why this was invisible from the accessibility tree alone.

Each is covered now; two of the new tests fail on the previous commit.

One fix broke another feature and had to be reconciled. Removing the blanket child-overflow escape restored the zone list's scrolling and immediately clipped the labels — that escape was what let the widened buttons paint outside the rail. A box cannot clip one axis and release the other, so the two cannot both hold at once. Scrolling is the half worth losing, and only while the labels are showing.

Also: the fade is absolute rather than fixed (it worked only because the rail happens to sit at the viewport edge and nothing enforces that), starts from the panel's own token rather than the page background (they differ, most visibly in dark theme, which put a step exactly at the seam), mirrors in RTL (a logical inset with a physical gradient put the opaque end on the wrong side and reinstated the hard cut it exists to remove), and renders only when the pane is beside the rail rather than collapsed off-canvas.

On the chart, the readout stood 26px above a strip 8px below its header, so it covered the two comparisons a reader hovers in order to reason against. It has headroom now, and it anchors to its bar's edge and grows inwards instead of centring — centring needs the rendered width to stay inside, and the constant guarding the ends was a guess that longer strings walked past.

The tests also mocked a layout value the app never produces, so the middle width tier — where the pane is collapsed and the fade has no work to do — was untested.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (4)
src/frontend/src/components/portal/lens-rail.test.tsx (2)

121-123: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move afterEach next to beforeEach.

afterEach sits at module scope between the two describe blocks. Vitest applies module-scope hooks to every test in the file, so this hook also runs for the first describe. The placement suggests it applies only to the second describe.

Move it directly after the beforeEach block at Line 69 so the scope is obvious.

🤖 Prompt for 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.

In `@src/frontend/src/components/portal/lens-rail.test.tsx` around lines 121 -
123, Move the module-scope afterEach hook using vi.useRealTimers() to
immediately follow the beforeEach block near the start of the second describe,
leaving its behavior unchanged and making its intended scope clear.

71-183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the three untested branches in LensRail.

The suite covers hover, click dismissal, keyboard, phone layout, and layout reset. Three changed branches in lens-rail.tsx have no coverage:

  • onPointerEnter early return when e.pointerType === "touch" (Line 152). A regression here would flash the labels on every tap.
  • onBlurCapture closing the rail when focus moves outside (Lines 167-172). This is the only path that closes a keyboard-opened rail.
  • The paneIsBeside guard on the fade element (Lines 186-209). The mocked SidebarProvider state drives this, so both states are reachable in jsdom.

As per coding guidelines: "Ensure new and changed lines achieve at least 80% test coverage."

Do you want me to draft these three test cases?

🤖 Prompt for 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.

In `@src/frontend/src/components/portal/lens-rail.test.tsx` around lines 71 - 183,
Add tests in the LensRail suites covering the three untested branches: verify
touch pointer entry does not reveal labels, verify a keyboard-opened rail closes
when focus moves outside via onBlurCapture, and verify the fade element behavior
for both paneIsBeside states using the mocked SidebarProvider layout state. Keep
existing interaction coverage intact and exercise each branch directly.

Source: Coding guidelines

src/frontend/src/components/portal/lens-rail.tsx (1)

201-201: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use Tailwind CSS 4 gradient utilities

Replace bg-gradient-to-r and rtl:bg-gradient-to-l with bg-linear-to-r and rtl:bg-linear-to-l. tailwind-merge 3.5.0 preserves both utilities.

🤖 Prompt for 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.

In `@src/frontend/src/components/portal/lens-rail.tsx` at line 201, Update the
gradient class in the lens rail component to use Tailwind CSS 4 utilities:
replace bg-gradient-to-r with bg-linear-to-r and rtl:bg-gradient-to-l with
rtl:bg-linear-to-l, preserving the existing gradient directions and other
classes.
src/frontend/src/components/widgets/metric-views/metric-activity.tsx (1)

322-340: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct coverage for the hover readout.

The current tests do not trigger onPointerEnter or assert the overlay. Add left-half and right-half cases that check the rendered dayTitle text, left anchor, and translateX(-100%) behavior.

🤖 Prompt for 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.

In `@src/frontend/src/components/widgets/metric-views/metric-activity.tsx` around
lines 322 - 340, Add direct tests for the hover readout rendered by the metric
activity component: trigger onPointerEnter for days in both the left and right
halves, assert the rendered dayTitle text and percentage-based left style, and
verify translateX(-100%) is absent on the left case but applied on the right
case.

Source: Coding guidelines

🤖 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 `@src/frontend/src/components/portal/lens-rail.test.tsx`:
- Around line 55-56: Update the beforeEach fake-timer setup to call
vi.useFakeTimers() without shouldAdvanceTime. Preserve the explicit
advanceTimers callback so the 200 ms opening timer remains under deterministic
test control.

In `@src/frontend/src/components/portal/lens-rail.tsx`:
- Around line 156-172: Update the onPointerLeave handler to receive the pointer
event and only call close() and setDismissed(false) when focus is not contained
within the current target, matching the containment guard used by onBlurCapture;
preserve the existing dismissal behavior when the rail has no focused
descendant.

In `@src/frontend/src/components/widgets/metric-views/metric-activity.tsx`:
- Around line 327-335: Update the readout positioning logic near the hovered-bar
style so the translated case uses the hovered bar’s right boundary, `(hovered +
1) / days.length * 100`, while preserving the existing left-boundary position
and no-transform behavior for left-side readouts.
- Around line 364-369: Update the constantDenominator display in the metric
activity view to use the denominator’s API-provided unit metadata instead of
hardcoding “per day”; support units such as chat_active_day,
total_chat_messages, and pr_created, and omit the unit when metadata is
unavailable while preserving the existing silent-reading fallback.

---

Nitpick comments:
In `@src/frontend/src/components/portal/lens-rail.test.tsx`:
- Around line 121-123: Move the module-scope afterEach hook using
vi.useRealTimers() to immediately follow the beforeEach block near the start of
the second describe, leaving its behavior unchanged and making its intended
scope clear.
- Around line 71-183: Add tests in the LensRail suites covering the three
untested branches: verify touch pointer entry does not reveal labels, verify a
keyboard-opened rail closes when focus moves outside via onBlurCapture, and
verify the fade element behavior for both paneIsBeside states using the mocked
SidebarProvider layout state. Keep existing interaction coverage intact and
exercise each branch directly.

In `@src/frontend/src/components/portal/lens-rail.tsx`:
- Line 201: Update the gradient class in the lens rail component to use Tailwind
CSS 4 utilities: replace bg-gradient-to-r with bg-linear-to-r and
rtl:bg-gradient-to-l with rtl:bg-linear-to-l, preserving the existing gradient
directions and other classes.

In `@src/frontend/src/components/widgets/metric-views/metric-activity.tsx`:
- Around line 322-340: Add direct tests for the hover readout rendered by the
metric activity component: trigger onPointerEnter for days in both the left and
right halves, assert the rendered dayTitle text and percentage-based left style,
and verify translateX(-100%) is absent on the left case but applied on the right
case.
🪄 Autofix

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: 37b1acc6-58c0-419e-b5a2-07f1f4650d2a

📥 Commits

Reviewing files that changed from the base of the PR and between 94cbbdd and 89566eb.

📒 Files selected for processing (3)
  • src/frontend/src/components/portal/lens-rail.test.tsx
  • src/frontend/src/components/portal/lens-rail.tsx
  • src/frontend/src/components/widgets/metric-views/metric-activity.tsx

Comment on lines +55 to +56
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Vitest useFakeTimers shouldAdvanceTime with user-event advanceTimers option conflict

💡 Result:

When using Vitest fake timers with the Testing Library user-event package, historical compatibility issues often lead to tests hanging or timing out [1][2]. These issues typically arise because user-event expects a specific timer advancement interface, historically modeled after Jest, which Vitest does not provide by default in the global scope [3][2]. There is no conflict between vi.useFakeTimers({ shouldAdvanceTime: true }) and the user-event advanceTimers option in terms of logical contradictions, but they serve different purposes and can be confusing when used together [4][5]. 1. Understanding the Options: - vi.useFakeTimers({ shouldAdvanceTime: true }): This tells Vitest to automatically increment the mocked clock based on the real system time passage [4]. It is rarely the recommended solution for standard component interaction tests, as it can introduce flakiness by tying your test execution speed to the real system clock [6]. - userEvent.setup({ advanceTimers: ... }): This allows user-event to trigger time advancement explicitly when it needs to wait for interaction delays [7][5]. 2. Resolution and Best Practices: - Modern versions of @testing-library/user-event (v14.4.0+) have built-in support to auto-detect Vitest's vi global [3]. This eliminates the need for manual configuration or global stubs in most cases [3]. - If you encounter issues, ensure you are using the latest version of user-event [3]. - If you must configure it manually, explicitly pass Vitest's advancement function [5]: const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); - Avoid shouldAdvanceTime: true unless you have a specific requirement to simulate real-time passage within a fake timer environment, as it often defeats the purpose of deterministic testing [6]. If you still experience timeouts, verify that you are awaiting the user-event calls (e.g., await user.click(...)) and that your test setup does not inadvertently leak timers by failing to call vi.useRealTimers() or vi.restoreAllMocks() in an afterEach block [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'src/frontend/src/components/portal/lens-rail.test.tsx' '*package.json' '*vitest*' '*vite.config*' | sed -n '1,120p'

printf '%s\n' '--- test structure and timer usage ---'
sed -n '1,230p' src/frontend/src/components/portal/lens-rail.test.tsx

printf '%s\n' '--- relevant dependency versions and configuration ---'
rg -n --glob 'package.json' --glob '*lock*' --glob '*vitest*' --glob '*vite.config*' \
  '(`@testing-library/user-event`|vitest|fakeTimers|shouldAdvanceTime|advanceTimers)' . | sed -n '1,220p'

Repository: constructorfabric/insight

Length of output: 19980


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
p = Path("src/frontend/src/components/portal/lens-rail.test.tsx")
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if "shouldAdvanceTime" in line or "advanceTimers" in line or "stays shut" in line or "hover(" in line or "unhover(" in line or "200" in line:
        print(f"{i}: {line}")
PY

Repository: constructorfabric/insight

Length of output: 1335


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- LensRail timer implementation ---'
rg -n -C 12 'setTimeout|clearTimeout|hover|pointer|open|close|isOpen' \
  src/frontend/src/components/portal/lens-rail.tsx src/frontend/src/components/portal/lens-rail.test.tsx

printf '%s\n' '--- fake-timer configuration ---'
sed -n '1,190p' src/frontend/vitest.config.ts

printf '%s\n' '--- user-event package metadata from lockfile ---'
sed -n '1565,1595p' src/frontend/pnpm-lock.yaml
sed -n '5500,5525p' src/frontend/pnpm-lock.yaml

Repository: constructorfabric/insight

Length of output: 50381


🌐 Web query:

site:vitest.dev/config/faketimers shouldAdvanceTime advanceTimersByTime real time

💡 Result:

  • shouldAdvanceTime: true makes Vitest advance fake time automatically according to real elapsed time. By default, it advances in 20 ms increments; configure this with advanceTimeDelta. [1]
  • vi.advanceTimersByTime(ms) is manual: it immediately advances mocked time by exactly ms, without waiting for real time. [2]
vi.useFakeTimers({
  shouldAdvanceTime: true,
  advanceTimeDelta: 50,
})

// Real 50 ms passing advances fake time by about 50 ms.

So, use shouldAdvanceTime when timers should track wall-clock time; use advanceTimersByTime for deterministic, instant test control. [1][2]


🌐 Web query:

site:testing-library.com/docs/user-event/options advanceTimers fake timers

💡 Result:

In testing environments, particularly when using testing-library's user-event, advanceTimers is a configuration option that allows the library to coordinate with your test runner's fake timers [1][2]. When user-event simulates interactions (such as typing or clicking), it often introduces artificial delays between events to mimic real user behavior [1]. When tests use fake timers (a feature provided by libraries like Jest or Vitest to mock time), these internal delays can conflict with the test runner, often causing tests to hang or time out if the fake clock is not advanced correctly [1][2]. To resolve this, you pass the test runner's time advancement function (e.g., jest.advanceTimersByTime or vitest.advanceTimersByTime) to the advanceTimers option in the userEvent.setup configuration [1][2]. This enables user-event to programmatically advance the fake clock by the necessary delay duration, allowing the simulated interactions to proceed without stalling the test [1]. Example usage with Jest: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); This approach is recommended over setting delay: null, as the latter can lead to unexpected behavior during test execution [1].

Citations:


🌐 Web query:

site:github.com/testing-library/user-event 14.6.1 advanceTimers pointer hover unhover delay

💡 Result:

In @testing-library/user-event v14.6.1, the advanceTimers option and the delay configuration are used to manage asynchronous timing, particularly when working with fake timers in testing frameworks like Jest [1][2]. The advanceTimers option allows you to integrate your test runner's timer-advancing function (e.g., jest.advanceTimersByTime) into the user-event workflow [1][2]. This ensures that internal delays used by the library—which are intended to move events to the next macro task and allow asynchronous code to process—do not cause tests to hang or timeout when fake timers are active [3][1][2]. Usage typically involves configuring it during the setup phase: const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); Regarding pointer interactions (hover/unhover) and delays: 1. The delay option dictates the pause duration between sequential user-event actions [1]. 2. By default, user-event introduces these small delays to better simulate real-user behavior and prevent race conditions with asynchronous state updates [1][2]. 3. If these internal delays conflict with your test environment (especially when using fake timers), you must either provide the advanceTimers callback to "fast-forward" through these delays [1] or, in specific scenarios where the overhead is not desired, set delay to null [1][2]. Version 14.6.1 included a fix to correct the documentation/description for the delay option [4], ensuring developers better understand how this configuration affects event execution timing.

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from urllib.request import urlopen
urls = {
    "user-event setup": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/setup/setup.js",
    "user-event wait": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/utils/misc/wait.js",
    "user-event pointer": "https://unpkg.com/@testing-library/user-event@14.6.1/dist/esm/system/pointer/index.js",
}
for name, url in urls.items():
    print(f"--- {name}: {url} ---")
    try:
        text = urlopen(url, timeout=10).read().decode()
    except Exception as exc:
        print(f"FETCH_ERROR: {exc}")
        continue
    for i, line in enumerate(text.splitlines(), 1):
        if any(term in line for term in ("advanceTimers", "delay", "setTimeout", "async pointer", "wait(")):
            print(f"{i}: {line}")
PY

printf '%s\n' '--- repository timing invariant ---'
python3 - <<'PY'
# Read-only timing model for the test's relevant sequence.
OPEN_AFTER_MS = 200
manual_user_event_advances = [0, 0]  # default user-event delay for hover/unhover
explicit_settle = 400
for real_elapsed in (0, 199, 200, 250):
    fake_before_settle = real_elapsed + sum(manual_user_event_advances)
    opened_before_settle = fake_before_settle >= OPEN_AFTER_MS
    print({
        "real_elapsed_between_hover_and_unhover_ms": real_elapsed,
        "fake_time_before_settle_ms": fake_before_settle,
        "open_timer_fired_before_unhover": opened_before_settle,
        "final_fake_time_after_settle_ms": fake_before_settle + explicit_settle,
    })
PY

Repository: constructorfabric/insight

Length of output: 1632


Remove shouldAdvanceTime from the fake-timer setup.

Real elapsed time can advance the mocked clock past the 200 ms opening timer before unhover. Keep vi.useFakeTimers() and the explicit advanceTimers callback for deterministic control.

🤖 Prompt for 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.

In `@src/frontend/src/components/portal/lens-rail.test.tsx` around lines 55 - 56,
Update the beforeEach fake-timer setup to call vi.useFakeTimers() without
shouldAdvanceTime. Preserve the explicit advanceTimers callback so the 200 ms
opening timer remains under deterministic test control.

Comment on lines +156 to +172
onPointerLeave={() => {
close();
setDismissed(false);
}}
// Keyboard gets the labels too, and immediately: a sighted keyboard user
// was tabbing through eight identical icons with the text at zero
// opacity, and `title` does not surface on focus in any browser.
onFocusCapture={() => {
cancel();
setOpen(true);
}}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
close();
setDismissed(false);
}
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

onPointerLeave collapses the rail while focus is still inside it.

onFocusCapture opens the rail for keyboard users. onPointerLeave then calls close() unconditionally. If a keyboard user has focus on a zone button and any pointer crosses and leaves the rail, the labels collapse while focus remains inside. The focused button then has a zero-opacity label again.

Guard the close on focus containment, the same way onBlurCapture does.

♿ Proposed fix
       onPointerLeave={() => {
-        close();
         setDismissed(false);
+        // Focus outranks the pointer: a keyboard user's labels must not
+        // vanish because an unrelated pointer crossed the rail.
+        if (e.currentTarget.contains(document.activeElement)) {
+          cancel();
+          return;
+        }
+        close();
       }}

Change the handler signature to onPointerLeave={(e) => { ... }} for this to compile.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onPointerLeave={() => {
close();
setDismissed(false);
}}
// Keyboard gets the labels too, and immediately: a sighted keyboard user
// was tabbing through eight identical icons with the text at zero
// opacity, and `title` does not surface on focus in any browser.
onFocusCapture={() => {
cancel();
setOpen(true);
}}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
close();
setDismissed(false);
}
}}
onPointerLeave={(e) => {
setDismissed(false);
// Focus outranks the pointer: a keyboard user's labels must not
// vanish because an unrelated pointer crossed the rail.
if (e.currentTarget.contains(document.activeElement)) {
cancel();
return;
}
close();
}}
// Keyboard gets the labels too, and immediately: a sighted keyboard user
// was tabbing through eight identical icons with the text at zero
// opacity, and `title` does not surface on focus in any browser.
onFocusCapture={() => {
cancel();
setOpen(true);
}}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
close();
setDismissed(false);
}
}}
🤖 Prompt for 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.

In `@src/frontend/src/components/portal/lens-rail.tsx` around lines 156 - 172,
Update the onPointerLeave handler to receive the pointer event and only call
close() and setDismissed(false) when focus is not contained within the current
target, matching the containment guard used by onBlurCapture; preserve the
existing dismissal behavior when the rail has no focused descendant.

Comment on lines +327 to +335
// Anchored to the bar's own edge and grown inwards, rather than
// centred and clamped. Centring needs to know the readout's
// width to keep it inside, and it does not: the text varies from
// "3 messages" to "3.5 hours of 8", so any constant guarding the
// ends is a guess that the longer strings walk straight past.
// Growing inwards cannot overflow whatever the text says.
left: `${(hovered / days.length) * 100}%`,
transform:
hovered < days.length / 2 ? undefined : "translateX(-100%)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Anchor right-side readouts to the bar's right edge.

When hovered >= days.length / 2, Line 335 applies translateX(-100%). Line 333 still uses the hovered bar's left boundary. The readout therefore ends at the left edge instead of starting at the right edge. The last bar is translated from (days.length - 1) / days.length, not from 100%.

Use the right boundary for the translated case:

Proposed fix
-              left: `${(hovered / days.length) * 100}%`,
+              left: `${((hovered + (hovered < days.length / 2 ? 0 : 1)) / days.length) * 100}%`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Anchored to the bar's own edge and grown inwards, rather than
// centred and clamped. Centring needs to know the readout's
// width to keep it inside, and it does not: the text varies from
// "3 messages" to "3.5 hours of 8", so any constant guarding the
// ends is a guess that the longer strings walk straight past.
// Growing inwards cannot overflow whatever the text says.
left: `${(hovered / days.length) * 100}%`,
transform:
hovered < days.length / 2 ? undefined : "translateX(-100%)",
// Anchored to the bar's own edge and grown inwards, rather than
// centred and clamped. Centring needs to know the readout's
// width to keep it inside, and it does not: the text varies from
// "3 messages" to "3.5 hours of 8", so any constant guarding the
// ends is a guess that the longer strings walk straight past.
// Growing inwards cannot overflow whatever the text says.
left: `${((hovered + (hovered < days.length / 2 ? 0 : 1)) / days.length) * 100}%`,
transform:
hovered < days.length / 2 ? undefined : "translateX(-100%)",
🤖 Prompt for 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.

In `@src/frontend/src/components/widgets/metric-views/metric-activity.tsx` around
lines 327 - 335, Update the readout positioning logic near the hovered-bar style
so the translated case uses the hovered bar’s right boundary, `(hovered + 1) /
days.length * 100`, while preserving the existing left-boundary position and
no-transform behavior for left-side readouts.

Comment on lines +364 to +369
<span className="text-center">
{constantDenominator != null
? `measured against ${constantDenominator} per day`
: silent > 0
? `${silent} ${silent === 1 ? "day" : "days"} with no reading`
: null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 5 'constantDenominator|denominator|metric\.unit|description|per day' \
  src/frontend/src || true

Repository: constructorfabric/insight

Length of output: 50382


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- metric-activity outline ---'
ast-grep outline src/frontend/src/components/widgets/metric-views/metric-activity.tsx || true

printf '%s\n' '--- relevant component sections ---'
sed -n '1,120p' src/frontend/src/components/widgets/metric-views/metric-activity.tsx
sed -n '260,390p' src/frontend/src/components/widgets/metric-views/metric-activity.tsx

printf '%s\n' '--- constantDenominator definitions and uses ---'
rg -n -C 8 'constantDenominator' src/frontend/src src/frontend --glob '*.{ts,tsx}' || true

printf '%s\n' '--- metric activity tests ---'
fd -i 'metric-activity' src/frontend
rg -n -C 6 'Activity|activity|no reading|measured against|per day|denominator' \
  src/frontend/src/components src/frontend/src/lib src/frontend/src/queries \
  --glob '*.{test,spec}.{ts,tsx}' || true

Repository: constructorfabric/insight

Length of output: 50381


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- metric activity test ---'
cat -n src/frontend/src/components/widgets/metric-views/metric-activity.test.tsx

printf '%s\n' '--- grain implementation and types ---'
cat -n src/frontend/src/lib/insight/day-strip.ts
cat -n src/frontend/src/lib/insight/metric-grain.ts

printf '%s\n' '--- API result and drilldown types ---'
sed -n '70,150p' src/frontend/src/api/metric-results-client.ts
cat -n src/frontend/src/api/metric-drilldown-client.ts | sed -n '1,180p'

printf '%s\n' '--- denominator references outside frontend ---'
python3 - <<'PY'
from pathlib import Path
for p in Path(".").rglob("*"):
    if not p.is_file() or any(part in {"node_modules", "dist", "build", ".git"} for part in p.parts):
        continue
    try:
        text = p.read_text(errors="ignore")
    except OSError:
        continue
    hits = [(i, line.strip()) for i, line in enumerate(text.splitlines(), 1)
            if "denominator" in line.lower()]
    if hits:
        print(f"\n{p}")
        for i, line in hits[:30]:
            print(f"{i}:{line}")
PY

Repository: constructorfabric/insight

Length of output: 44503


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- metric design contract ---'
sed -n '205,305p' docs/domain/metrics/specs/DESIGN.md

printf '%s\n' '--- drilldown DTO and presentation ---'
sed -n '145,180p' src/backend/services/analytics/src/domain/metric_drilldown/dto.rs
sed -n '80,155p' src/backend/services/analytics/src/domain/metric_drilldown/presentation.rs
sed -n '180,215p' src/backend/services/analytics/src/domain/metric_drilldown/presentation.rs

printf '%s\n' '--- concrete denominator measures in the metric registry ---'
sed -n '250,310p' src/backend/services/analytics/src/domain/metric_definitions/registry.yaml
sed -n '480,550p' src/backend/services/analytics/src/domain/metric_definitions/registry.yaml
sed -n '640,695p' src/backend/services/analytics/src/domain/metric_definitions/registry.yaml

printf '%s\n' '--- API schema for drilldown denominator ---'
sed -n '880,920p' docs/components/backend/analytics/openapi.json

Repository: constructorfabric/insight

Length of output: 17148


Use the denominator's declared unit. Denominators represent different measures, such as chat_active_day, total_chat_messages, and pr_created; per day is not universal. Use API-provided denominator metadata or omit the unit until the API supplies it.

🤖 Prompt for 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.

In `@src/frontend/src/components/widgets/metric-views/metric-activity.tsx` around
lines 364 - 369, Update the constantDenominator display in the metric activity
view to use the denominator’s API-provided unit metadata instead of hardcoding
“per day”; support units such as chat_active_day, total_chat_messages, and
pr_created, and omit the unit when metadata is unavailable while preserving the
existing silent-reading fallback.

Source: Coding guidelines

@dzarlax
dzarlax added this pull request to the merge queue Aug 11, 2026
Merged via the queue into constructorfabric:main with commit dc4b218 Aug 11, 2026
53 checks passed
@dzarlax
dzarlax deleted the feat/rail-and-chart-hover branch August 11, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants