Skip to content

fix(ui): history fan — stable expansion and newest-first ordering - #66

Open
MA1503 wants to merge 2 commits into
nikmcfly:mainfrom
MA1503:fix/history-fan-stability
Open

fix(ui): history fan — stable expansion and newest-first ordering#66
MA1503 wants to merge 2 commits into
nikmcfly:mainfrom
MA1503:fix/history-fan-stability

Conversation

@MA1503

@MA1503 MA1503 commented Jul 25, 2026

Copy link
Copy Markdown

Two independent bugs in frontend/src/components/HistoryDatabase.vue, one commit each.

1. History records were shown in arbitrary order

loadHistory() assigned the API response straight to projects:

projects.value = response.data || []

The history endpoint does not guarantee an order, so the card fan could show older
runs in front of newer ones — a run you just finished ended up somewhere in the
middle of the stack instead of at the front, which is the one place you look for it.

Fixed by sorting client-side, newest first. created_at values are ISO strings, so
they compare correctly lexicographically and no date parsing is needed; records with
a missing created_at sort last:

projects.value = (response.data || []).slice().sort(
  (a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))
)

.slice() keeps the sort off the response object. No other behaviour changes.

2. The fan collapsed itself immediately after expanding

Opening the records section made it snap shut again about 850ms later, with no
scroll movement
. It reproduces reliably once the record count exceeds a single grid
row (roughly 5+ records); with fewer records the section barely grows and the bug
stays hidden, which is why it can look intermittent.

Why it happens

The IntersectionObserver observes the very container that expansion resizes:

  • collapsed (fan layout) the section measures 551px
  • expanded (grid layout) it measures 1637px

The visible slice of the page does not change, so intersectionRatio falls from
0.45 to 0.375 purely as a side effect of the element growing. That drops below
the observer's lowest threshold (0.4), and this is the part that turns a cosmetic
detail into a bug: Blink derives entry.isIntersecting from the crossed threshold
index, not from actual visibility.
With threshold: [0.4, 0.6, 0.8] any ratio
below 0.4 is therefore reported as isIntersecting: false, even though several
hundred pixels of the section are plainly on screen.

The callback used that value directly:

const shouldExpand = entry.isIntersecting

so expanding produced the observer event that collapsed it again. The existing
debounce and animation lock cannot help here — the event is not flicker, it is a
single, confident, wrong "not visible" report arriving right after the animation
lock releases.

The fix

Hysteresis, with separate conditions for entering and staying in the expanded state:

const EXPAND_RATIO = 0.4

const shouldExpand = isExpanded.value
  ? entry.isIntersecting
  : (entry.isIntersecting && entry.intersectionRatio >= EXPAND_RATIO)

Opening still requires a decent share of the section to be visible, so the section
does not pop open when it is only peeking into the viewport. Staying open requires
only that it is visible at all, so the element's own growth can no longer close it.

The threshold list gains a 0 entry:

threshold: [0, EXPAND_RATIO, 0.6, 0.8],

This is required, not cosmetic: without a 0 threshold there is no crossing for
Blink to report below 0.4, so isIntersecting would never become true again in that
range and the hysteresis branch would never see the state it exists to handle. The
0.4 literal is now the named EXPAND_RATIO so the gate and the threshold cannot
drift apart.

rootMargin, the debounce delays, the animation lock and the collapse-on-scroll-out
behaviour are all untouched.

Verification

Measured in Chromium via Playwright, counting expand/collapse transitions per run:

transitions spurious
before 6 3
after 3 0

The remaining 3 are the intended ones (scroll in, scroll out, card click). Collapse
on scroll-out and on card clicks still work; expansion no longer triggers below the
intended visibility share.

The sort and the hysteresis fix are independent — the ordering change did not cause
the collapse, the record count crossing one grid row did.

🤖 Generated with Claude Code

MA1503 and others added 2 commits July 25, 2026 13:16
The history endpoint returns projects unsorted, so the card fan showed
runs in arbitrary order — a fresh run could appear anywhere in the stack
instead of at the front. Sort client-side by created_at descending
(ISO strings compare lexicographically, so localeCompare is stable and
needs no date parsing). Records with a missing created_at sort last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expanding the simulation-records fan grows the observed container itself
(~550px fan -> 1600px+ grid once the record count exceeds one grid row),
so its intersectionRatio drops below the lowest observer threshold (0.4)
and Chromium reports isIntersecting=false while a large part of the
section is still on screen — isIntersecting is derived from the crossed
threshold index, not from actual visibility. The section then collapsed
itself roughly 850ms after opening, with no scroll movement at all.

Fix: hysteresis. The initial expansion still requires ratio >= 0.4, but
staying expanded only requires any visibility. A 0 threshold is added,
which is mandatory for isIntersecting to be reported as true below 0.4.
Collapse on scroll-out and card clicks are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant