Files
experiments-database/docs/superpowers/plans/2026-06-01-cross-subject-plot-interactions.md
2026-06-01 09:26:00 -04:00

24 KiB
Raw Permalink Blame History

Cross-subject Plot Interactions Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a subject-visibility sidebar, legend click-to-toggle, value-ordered tooltips, and legend/sidebar hover highlight to the "Cross-subject metrics" plot.

Architecture: Extract pure interaction logic into src/lib/crossSubjectChart.js (unit-tested), build a standalone presentational SubjectSidebar component (unit-tested with RTL), then wire shared client-side state (hiddenSubjects, highlightedSubject, sidebarOpen) into ExperimentAnalysisCharts and thread it through both Recharts LineCharts. Visibility is global per-subject (affects both charts).

Tech Stack: React 18, Recharts 2.15, Jest + React Testing Library, Tailwind.

Constraints: Frontend only — no backend/API/DB changes, nothing persisted. Do not alter the sibling SubjectAnalysisCharts or the existing controls (X axis, Color by, tick step, per-chart customize bars, Y-metric selector).


Task 1: Pure interaction helpers

Files:

  • Create: frontend/src/lib/crossSubjectChart.js

  • Test: frontend/tests/crossSubjectChart.test.js

  • Step 1: Write the failing tests

// frontend/tests/crossSubjectChart.test.js
import {
  sortPayloadByValueDesc,
  toggleInSet,
  setIdsHidden,
  groupVisibilityState,
  groupLines,
} from '../src/lib/crossSubjectChart';

describe('sortPayloadByValueDesc', () => {
  it('sorts by value descending and drops null/undefined values', () => {
    const payload = [
      { dataKey: 'a', value: 40 },
      { dataKey: 'b', value: null },
      { dataKey: 'c', value: 92 },
      { dataKey: 'd', value: 78 },
    ];
    expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
  });
  it('returns [] for non-array input', () => {
    expect(sortPayloadByValueDesc(undefined)).toEqual([]);
  });
  it('does not mutate the input array', () => {
    const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
    sortPayloadByValueDesc(payload);
    expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
  });
});

describe('toggleInSet', () => {
  it('adds a missing id and removes a present id, returning a new Set', () => {
    const a = new Set(['x']);
    const added = toggleInSet(a, 'y');
    expect([...added].sort()).toEqual(['x', 'y']);
    expect(added).not.toBe(a);
    const removed = toggleInSet(added, 'x');
    expect([...removed]).toEqual(['y']);
  });
});

describe('setIdsHidden', () => {
  it('adds all ids when hidden=true', () => {
    expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
  });
  it('removes all ids when hidden=false', () => {
    expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
  });
  it('returns a new Set', () => {
    const s = new Set();
    expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
  });
});

describe('groupVisibilityState', () => {
  it("returns 'all' when none of the ids are hidden", () => {
    expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
  });
  it("returns 'none' when all ids are hidden", () => {
    expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
  });
  it("returns 'some' when a subset is hidden", () => {
    expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
  });
  it("returns 'none' for an empty group", () => {
    expect(groupVisibilityState([], new Set())).toBe('none');
  });
});

describe('groupLines', () => {
  it('groups by .group preserving first-seen group order and within-group order', () => {
    const lines = [
      { id: '1', group: 'B' },
      { id: '2', group: 'A' },
      { id: '3', group: 'B' },
    ];
    const out = groupLines(lines);
    expect(out.map((g) => g.group)).toEqual(['B', 'A']);
    expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
  });
  it("uses '—' for lines with no group", () => {
    expect(groupLines([{ id: '1' }])[0].group).toBe('—');
  });
});
  • Step 2: Run tests to verify they fail

Run: cd frontend && npx jest tests/crossSubjectChart.test.js Expected: FAIL — cannot find module ../src/lib/crossSubjectChart.

  • Step 3: Write the implementation
// frontend/src/lib/crossSubjectChart.js
// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.

// Sort a Recharts tooltip payload by numeric value descending, dropping entries
// whose value is null/undefined. Returns a NEW array (does not mutate input).
export function sortPayloadByValueDesc(payload) {
  if (!Array.isArray(payload)) return [];
  return payload
    .filter((p) => p && p.value != null)
    .slice()
    .sort((a, b) => b.value - a.value);
}

// Toggle an id's membership in a Set, returning a NEW Set.
export function toggleInSet(set, id) {
  const next = new Set(set);
  if (next.has(id)) next.delete(id);
  else next.add(id);
  return next;
}

// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
// Returns a NEW Set.
export function setIdsHidden(set, ids, hidden) {
  const next = new Set(set);
  for (const id of ids) {
    if (hidden) next.add(id);
    else next.delete(id);
  }
  return next;
}

// Tri-state for a group checkbox given which subject ids are hidden.
// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
export function groupVisibilityState(subjectIds, hiddenSet) {
  if (subjectIds.length === 0) return 'none';
  const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
  if (hiddenCount === 0) return 'all';
  if (hiddenCount === subjectIds.length) return 'none';
  return 'some';
}

// Group chart "lines" by their .group field, preserving first-seen group order
// and line order within each group. Returns [{ group, subjects: [line...] }].
export function groupLines(lines) {
  const order = [];
  const byGroup = new Map();
  for (const line of lines) {
    const g = line.group ?? '—';
    if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
    byGroup.get(g).push(line);
  }
  return order.map((group) => ({ group, subjects: byGroup.get(group) }));
}
  • Step 4: Run tests to verify they pass

Run: cd frontend && npx jest tests/crossSubjectChart.test.js Expected: PASS — all describe blocks green.

  • Step 5: Commit
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
git commit -m "feat(frontend): pure helpers for cross-subject plot interactions"

Task 2: SubjectSidebar component

Files:

  • Create: frontend/src/components/SubjectSidebar.jsx

  • Test: frontend/tests/SubjectSidebar.test.jsx

  • Step 1: Write the failing tests

// frontend/tests/SubjectSidebar.test.jsx
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { SubjectSidebar } from '../src/components/SubjectSidebar';

const LINES = [
  { id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
  { id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
  { id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
];

function setup(overrides = {}) {
  const props = {
    lines: LINES,
    hiddenSubjects: new Set(),
    onToggleSubject: jest.fn(),
    onToggleGroup: jest.fn(),
    onShowAll: jest.fn(),
    onHighlight: jest.fn(),
    ...overrides,
  };
  render(<SubjectSidebar {...props} />);
  return props;
}

it('renders each group header with its subject count', () => {
  setup();
  expect(screen.getByText('Group A')).toBeInTheDocument();
  expect(screen.getByText('Group B')).toBeInTheDocument();
  expect(screen.getByText('(2)')).toBeInTheDocument();
  expect(screen.getByText('(1)')).toBeInTheDocument();
});

it('renders a subject checkbox per subject, checked when visible', () => {
  setup({ hiddenSubjects: new Set(['a2']) });
  expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
  expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
});

it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
  const { onToggleSubject } = setup();
  fireEvent.click(screen.getByLabelText('Mouse-A1'));
  expect(onToggleSubject).toHaveBeenCalledWith('a1');
});

it('shows the group checkbox as indeterminate when the group is mixed', () => {
  setup({ hiddenSubjects: new Set(['a1']) });
  const groupCheckbox = screen.getByLabelText(/Group A/);
  expect(groupCheckbox.indeterminate).toBe(true);
  expect(groupCheckbox.checked).toBe(false);
});

it('checks the group checkbox when all members are visible', () => {
  setup();
  expect(screen.getByLabelText(/Group A/).checked).toBe(true);
});

it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
  const { onToggleGroup } = setup();
  fireEvent.click(screen.getByLabelText(/Group A/));
  expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
});

it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
  const { rerender } = render(
    <SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
      onToggleSubject={() => {}} onToggleGroup={() => {}}
      onShowAll={() => {}} onHighlight={() => {}} />,
  );
  expect(screen.queryByText('Show all')).toBeNull();
  rerender(
    <SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
      onToggleSubject={() => {}} onToggleGroup={() => {}}
      onShowAll={() => {}} onHighlight={() => {}} />,
  );
  expect(screen.getByText('Show all')).toBeInTheDocument();
});

it('calls onShowAll when the Show all link is clicked', () => {
  const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
  fireEvent.click(screen.getByText('Show all'));
  expect(onShowAll).toHaveBeenCalled();
});

it('calls onHighlight on subject row hover enter/leave', () => {
  const { onHighlight } = setup();
  const row = screen.getByLabelText('Mouse-A1').closest('label');
  fireEvent.mouseEnter(row);
  expect(onHighlight).toHaveBeenCalledWith('a1');
  fireEvent.mouseLeave(row);
  expect(onHighlight).toHaveBeenCalledWith(null);
});
  • Step 2: Run tests to verify they fail

Run: cd frontend && npx jest tests/SubjectSidebar.test.jsx Expected: FAIL — cannot find module ../src/components/SubjectSidebar.

  • Step 3: Write the implementation

Note: the group checkbox's accessible name comes from the group <span> text inside its <label>, so getByLabelText(/Group A/) matches it; subject checkboxes are matched by their name text. The group <label> text includes the count, so its accessible name is e.g. "Group A (2)" — /Group A/ regex still matches.

// frontend/src/components/SubjectSidebar.jsx
import React from 'react';
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';

// Presentational panel listing subjects grouped by their group, with per-subject
// and per-group visibility checkboxes. All state lives in the parent; this component
// only renders and calls handlers.
export function SubjectSidebar({
  lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
}) {
  const groups = groupLines(lines);
  const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));

  return (
    <div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
        {anyHidden && (
          <button type="button" onClick={onShowAll}
            className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
        )}
      </div>
      <div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
        {groups.map(({ group, subjects }) => {
          const ids = subjects.map((s) => s.id);
          const state = groupVisibilityState(ids, hiddenSubjects);
          return (
            <div key={group}>
              <label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
                <input type="checkbox"
                  checked={state === 'all'}
                  ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
                  onChange={() => onToggleGroup(ids, state === 'all')}
                  className="accent-indigo-500" />
                <span className="truncate">{group}</span>
                <span className="text-gray-400">({subjects.length})</span>
              </label>
              <div className="mt-1 space-y-0.5 pl-1">
                {subjects.map((s) => (
                  <label key={s.id}
                    onMouseEnter={() => onHighlight(s.id)}
                    onMouseLeave={() => onHighlight(null)}
                    className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
                    <input type="checkbox"
                      checked={!hiddenSubjects.has(s.id)}
                      onChange={() => onToggleSubject(s.id)}
                      className="accent-indigo-500" />
                    <span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
                      style={{ backgroundColor: s.color }} />
                    <span className="truncate">{s.name}</span>
                  </label>
                ))}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}
  • Step 4: Run tests to verify they pass

Run: cd frontend && npx jest tests/SubjectSidebar.test.jsx Expected: PASS — all tests green.

  • Step 5: Commit
git add frontend/src/components/SubjectSidebar.jsx frontend/tests/SubjectSidebar.test.jsx
git commit -m "feat(frontend): add SubjectSidebar visibility panel"

Task 3: Wire state, sidebar, and behaviors into ExperimentAnalysisCharts

Files:

  • Modify: frontend/src/components/AnalysisCharts.jsx (ExperimentAnalysisCharts, lines ~446641; imports at top)

  • Test: frontend/tests/ExperimentAnalysisCharts.test.jsx (create)

  • Step 1: Add imports

At the top of frontend/src/components/AnalysisCharts.jsx, add after the existing date-fns import:

import { SubjectSidebar } from './SubjectSidebar';
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
  • Step 2: Add shared state and handlers

In ExperimentAnalysisCharts, immediately after the existing const [tickStep, setTickStep] = useState(1); line (~454), add:

  // Cross-subject interaction state (client-only; global across both charts)
  const [sidebarOpen,        setSidebarOpen]        = useState(false);
  const [hiddenSubjects,     setHiddenSubjects]     = useState(() => new Set());
  const [highlightedSubject, setHighlightedSubject] = useState(null);

  const toggleHidden = (id)          => setHiddenSubjects((prev) => toggleInSet(prev, id));
  const toggleGroup  = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
  const showAll      = ()            => setHiddenSubjects(new Set());
  • Step 3: Update the ExpTooltip to order rows by value descending

Replace the existing ExpTooltip function (~518533) with:

  function ExpTooltip({ active, payload, label, unit = '' }) {
    if (!active || !payload?.length) return null;
    const rows = sortPayloadByValueDesc(payload);
    return (
      <div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
        <p className="font-semibold text-gray-700 mb-1">{label}</p>
        {rows.map((p) => {
          const line = lines.find((l) => l.id === p.dataKey);
          return (
            <p key={p.dataKey} style={{ color: p.stroke }}>
              {line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
            </p>
          );
        })}
      </div>
    );
  }
  • Step 4: Add the sidebar toggle button to the global controls row

In the global-controls <div className="flex items-center flex-wrap gap-3"> block, immediately after the <TickStepControl ... /> line (~556), add:

        {hasData && (
          <button type="button" onClick={() => setSidebarOpen((o) => !o)}
            className={[
              'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
              sidebarOpen
                ? 'bg-gray-100 border-gray-300 text-gray-700'
                : 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
            ].join(' ')}>
            {sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
          </button>
        )}

Note: hasData is declared at ~509 (const hasData = lines.length > 0;), before the return, so it is in scope here.

  • Step 5: Wrap the charts in a flex row with the sidebar

Replace the {hasData && ( fragment opener and its closing for the charts block. The current structure is:

      {hasData && (
        <>
          {/* Chart 1 — counts */}
          <div> ... </div>
          {/* Chart 2 — success rate */}
          <div> ... </div>
        </>
      )}

Change ONLY the wrapper (keep both chart <div> blocks exactly as they are between the markers):

      {hasData && (
        <div className="flex gap-3 items-start">
          <div className="flex-1 min-w-0 space-y-5">
            {/* Chart 1 — counts */}
            {/* ...unchanged chart 1 <div> ... */}
            {/* Chart 2 — success rate */}
            {/* ...unchanged chart 2 <div> ... */}
          </div>
          {sidebarOpen && (
            <SubjectSidebar
              lines={lines}
              hiddenSubjects={hiddenSubjects}
              onToggleSubject={toggleHidden}
              onToggleGroup={toggleGroup}
              onShowAll={showAll}
              onHighlight={setHighlightedSubject}
            />
          )}
        </div>
      )}

min-w-0 on the chart column lets the flex child shrink so ResponsiveContainer re-flows when the sidebar is open.

  • Step 6: Add legend handlers and per-line hide/highlight to BOTH charts

In the counts chart, replace the <Legend ... /> (~602603) with:

                <Legend wrapperStyle={{ fontSize: 11 }}
                  formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
                  onClick={(o) => toggleHidden(o.dataKey)}
                  onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
                  onMouseLeave={() => setHighlightedSubject(null)} />

and replace its lines.map(...) <Line> block (~604608) with:

                {lines.map((l) => (
                  <Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
                    stroke={l.color}
                    strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
                    hide={hiddenSubjects.has(l.id)}
                    dot={{ r: 3 }} activeDot={{ r: 5 }}
                    connectNulls isAnimationActive={false} />
                ))}

In the success-rate chart, make the identical replacements for its <Legend ... /> (~627628) and its lines.map(...) <Line> block (~629633) — same two snippets above.

  • Step 7: Write a smoke test for the wiring
// frontend/tests/ExperimentAnalysisCharts.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
jest.mock('recharts', () => {
  const Pass = ({ children }) => <div>{children}</div>;
  return {
    ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
    XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
    Tooltip: () => null, Legend: () => null,
  };
});

import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';

const ANIMALS = [
  { id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
  { id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
];
const STATUSES = [
  { animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
  { animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
];
const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
const DAILY_TEMPLATE = [];

function renderChart() {
  render(
    <ExperimentAnalysisCharts
      animals={ANIMALS}
      experimentStatuses={STATUSES}
      subjectTemplate={SUBJECT_TEMPLATE}
      dailyTemplate={DAILY_TEMPLATE}
    />,
  );
}

it('toggles the subject sidebar open and closed', () => {
  renderChart();
  expect(screen.queryByText('Subjects')).toBeNull(); // closed: panel header not rendered
  fireEvent.click(screen.getByText('▸ Subjects'));
  // panel header "Subjects" + both subjects now visible
  expect(screen.getByText('Subjects')).toBeInTheDocument();
  expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
  expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
  fireEvent.click(screen.getByText('◂ Subjects'));
  expect(screen.queryByText('Subjects')).toBeNull();
});

it('unchecking a subject in the sidebar persists across re-render', () => {
  renderChart();
  fireEvent.click(screen.getByText('▸ Subjects'));
  const cb = screen.getByLabelText('Mouse-A1');
  expect(cb.checked).toBe(true);
  fireEvent.click(cb);
  expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
  // hiding one subject reveals the Show all link
  expect(screen.getByText('Show all')).toBeInTheDocument();
});

Note: the controls header <h2> text is "Cross-subject metrics"; the sidebar panel header text is "Subjects". They are distinct, so queryByText('Subjects') is null until the panel opens.

  • Step 8: Run the smoke test to verify it passes

Run: cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx Expected: PASS — both tests green.

  • Step 9: Run the full frontend test suite to confirm no regressions

Run: cd frontend && npm test Expected: PASS — all existing suites plus the three new files green.

  • Step 10: Build to confirm no syntax/compile errors

Run: cd frontend && npm run build Expected: Vite build succeeds with no errors.

  • Step 11: Commit
git add frontend/src/components/AnalysisCharts.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
git commit -m "feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot"

Manual Verification (after Task 3)

On the ExperimentDetail page for an experiment with animals and saved analysis metrics:

  • ▸ Subjects button toggles the sidebar; charts re-flow narrower while open.
  • Unchecking a subject removes its line from BOTH charts; its legend entry greys out and stays clickable.
  • Re-checking (sidebar) or clicking the greyed legend entry restores the line in both charts.
  • Group checkbox toggles all members; shows indeterminate () when the group is mixed.
  • "Show all" appears only when something is hidden and clears all hidden state.
  • Clicking a legend entry in either chart toggles that subject in both.
  • Hover tooltip lists subjects highest-value-first, matching their vertical order.
  • Hovering a legend entry OR a sidebar row thickens that subject's line in both charts; leaving restores it.
  • Existing controls still work: X axis, Color by, tick step, per-chart customize (size/Y range/X zoom/X-field), Y-metric selector. Sibling "Session metrics over time" chart is unchanged.

Self-Review Notes

  • Spec coverage: sidebar + groups + per-subject/per-group checkboxes (Task 2, Task 3 wiring); global visibility via hiddenSubjects (Task 1 toggleInSet/setIdsHidden, Task 3); legend click toggle (Task 3 Step 6); value-ordered tooltip (Task 1 sortPayloadByValueDesc, Task 3 Step 3); legend + sidebar hover highlight via highlightedSubject + strokeWidth (Task 2 onHighlight, Task 3 Steps 2/6). All four behaviors covered.
  • Naming consistency: toggleHidden, toggleGroup, showAll, setHighlightedSubject, hiddenSubjects, highlightedSubject, sidebarOpen used identically across Task 2 props and Task 3 wiring.
  • No DB/API: all state is useState in the component; no imports from api/.