diff --git a/TODOS.md b/TODOS.md index cff65cfc..d91576f6 100644 --- a/TODOS.md +++ b/TODOS.md @@ -36,6 +36,7 @@ Deferred and in-flight work. Keep this current — when something ships, delete - Design preview box (`h-[330px]`) clips the participant screens, so the keycaps need scrolling. Fix during the WS4 Prepare integration. ## Next (V1.5: Visual Polish and Juice) +- [ ] **Cleaning UXR playtest** (added 2026-09-25, after the WS6 Clean design #280) — a naive user, with no facilitator help, cleans one real recording in the redesigned Clean screen. Can they find the job (leave out noisy trials, flag a bad sensor, review auto-flag suggestions, watch the Live ERP change, save), and do they understand why cleaning comes before Analyze? Run after WS6 integration, before WS8's full second playtest. - [ ] **Participant screens for imported jsPsych/lab.js studies** (deferred 2026-09-25): wrap author timelines with BrainWaves instruction/transition/end screens. - [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces//stimuli/<condition>/` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow. - [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker. diff --git a/docs/uxr/2026-09-25-ws6-design-brief.md b/docs/uxr/2026-09-25-ws6-design-brief.md new file mode 100644 index 00000000..be7eb3f9 --- /dev/null +++ b/docs/uxr/2026-09-25-ws6-design-brief.md @@ -0,0 +1,72 @@ +# WS6 design brief — Clean + +**Gate:** plan §10.1 step 2. Storybook stories with fixture data only, no runtime wiring. Engineering integrates after product approves. + +**Sources:** +- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §1.5, §8, §10.2 ("Clean"), §11 WS6. +- `docs/uxr/playtest_naive_1.md` finding 7. +- `docs/uxr/# Playtest Takeaways.md`, the "Cleaning Data" section. +- Design system: `docs/design/DESIGN.md`, `.design-sync/conventions.md`. +- Shape to match, merged: **#277 Analyze**. Use its left controls rail + results layout, its walkthrough step panel and its "no scrolling to reach results" rule, so Clean and Analyze read as one family. + +## The job + +A student has raw EEG recordings and must decide which trials and sensors are too noisy to keep before averaging. The real job (§8.2): +1. click a noisy epoch to exclude or restore it; +2. click a channel when one sensor is consistently bad; +3. treat auto-flags as suggestions to review; +4. watch the Live ERP update as exclusions change; +5. save the cleaned dataset and continue. + +The playtester didn't know what cleaning meant, when it comes before analysis, or what her job was on the screen. #271 added a primer and clearer action names; the layout itself was never redesigned. + +## Current code (read before designing) + +- `src/renderer/components/CleanComponent/index.tsx`: the screen. It has a dataset-select phase and a review phase, the #271 primer ("What does cleaning your data mean?"), and the actions `Start cleaning →`, `← Pick different data`, `Apply exclusions & save`, `Save cleaned dataset & analyze →`, plus confirmations for rejecting all, removing selected, and dropping multiple channels. +- `src/renderer/components/CleanComponent/EpochReviewer.tsx`: the canvas epoch reviewer (columns = epochs, click to exclude; channel labels click to flag). +- `src/renderer/components/CleanComponent/LiveErpPane.tsx`: the live ERP. +- `src/renderer/components/CleanComponent/epochArrays.ts`: decoding and `meanTrace`. Its data is `EpochArraysMeta` (`src/renderer/actions/pyodideActions.ts`) plus a Float32Array [epoch][channel][time]. +- `src/renderer/components/Analyze/fixtures.ts`: `EXAMPLE_EPOCH_ARRAYS` already exists in that shape. Reuse or extend it, and don't make a second synthetic generator. +- Auto-flag suggestions: `SuggestedRejection { index, reason }`, with the threshold `<input>` in CleanComponent. +- Incomplete runs (#275): ended-early recordings are renamed `*.incomplete.csv` and hidden from ordinary discovery. +- **Try to render the real `EpochReviewer` and `LiveErpPane` with fixture props in Storybook.** If they need Redux or the worker, wrap them in a thin fixture adapter inside the stories. Don't edit them. If they can't render at all, draw faithful stand-ins and say so in the PR. + +## Stories required (plan §8, §10.2) + +Pure-props components under `src/renderer/components/Clean/`, with fixtures and stories, rendered inside the real AppShell chrome (`location='clean'`, a workspace, truthful badges and Next). + +| Story | Must show | +|---|---| +| DatasetSelect | Choose a complete raw recording. Ordinary list only; one primary `Start cleaning`. | +| DatasetSelectWithIncomplete | Incomplete recordings hidden by default, with a quiet "N ended-early recordings hidden — show" reveal. Revealed, they're clearly marked incomplete and not selectable as cleaning candidates, with a destructive-styled, confirmed `Delete` (§1.5, §11 WS6). | +| Loading | Loading epochs, explicit. | +| NoEpochs | The recording produced no usable epochs: why, and what to do. | +| Primer (first view of review) | A compact, always-available primer teaching the loop in §8.2, using the §8.1 definition. It collapses once the student interacts. No persisted first-use flag (§13). Consider #277's step-panel idiom with pointers at the reviewer. | +| Review | The Epoch Reviewer and Live ERP visible together as a coordinated pair (§8.3), plus the controls rail (dataset, back to selection, auto-flag threshold, exclusions summary). Rule A: everything on screen at once. | +| ReviewWithSelections | Several epochs excluded and one channel flagged, with the Live ERP visibly changed and the counts in words. | +| AutoFlagSuggestions | Suggestions shown as suggestions (distinct from the student's own exclusions), with review, accept and restore. | +| ConfirmRejectAll / ConfirmDropChannels | The existing confirmations, restyled (§8.3 keeps them). | +| Saving / SaveFailed / Saved | Save in progress, failure with retry, and success pointing to `Analyze` as the next step. | +| BehaviorOnly note | Not a story: behavior-only workspaces have no Clean area (WS1). Confirm this in the PR; don't design one. | + +## Constraints + +- Keep the epoch and channel selection behavior, save semantics and confirmations. Rename actions to describe their effect (§8.3). +- The route back to dataset selection stays (§8.3). +- The stale cleaning sidebar is gone (#271). Don't bring back saline or live-signal lessons (§8.3). +- The original recording is never modified, and the copy says so (§8.1). +- Condition colors come from `conditionPalette`. Signal-quality colors are not used for exclusions. Never color-only. +- One filled-teal primary per surface. Light headings. Student-facing copy. +- Rule A: at 1366×768 and 1280×720, the reviewer, the Live ERP and the controls are all visible with no page scroll. Measure and report. +- Traps in `.llms/learnings.md` (18px root, global `p`/`li`/lab.css). +- New files + stories only. Scoped CSS is fine; never modify shared global CSS classes. No new dependencies. Don't edit CleanComponent/*, epics, the worker or Python. + +## Out of scope + +Wiring, auto-flag algorithm changes, the Epoch reviewer Phase 3 guided mode's full curriculum (TODOS; OQ3 still open), Analyze. + +## Done when + +- Every story renders inside the real shell chrome, is screenshotted at both sizes, and has 0 console errors. +- `npx tsc --noEmit` is clean. +- A PR with the review agenda, open questions, whether the real EpochReviewer/LiveErpPane rendered, and any unmet constraint. diff --git a/src/renderer/components/Clean/Clean.stories.tsx b/src/renderer/components/Clean/Clean.stories.tsx new file mode 100644 index 00000000..39f21f15 --- /dev/null +++ b/src/renderer/components/Clean/Clean.stories.tsx @@ -0,0 +1,329 @@ +import React, { useState } from 'react'; +import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { fn } from 'storybook/test'; +import AppShell from '../AppShell/AppShell'; +import type { Area } from '../AppShell/types'; +import CleanDatasetSelect from './CleanDatasetSelect'; +import CleanReview, { + CleanConfirm, + CleanSuggestion, + SaveState, +} from './CleanReview'; +import { PrimerStep } from './CleanPrimer'; +import { + EXAMPLE_EPOCH_ARRAYS, + FACES_HOUSES_CODE_TO_LABEL, + RAW_RECORDINGS, + SUGGESTED_REJECTIONS, + WORKSPACE_TITLE, + RawRecording, +} from './fixtures'; + +interface ChromeParameters { + /** Shell badges for the story's data state (`useWorkspaceProgress.summarize`). */ + badges?: Partial<Record<Area, string[]>>; + nextArea?: Area; +} + +/** + * Storybook deep-merges object parameters, so stories set `badges` whole and + * the fresh-workspace default lives here rather than in `meta.parameters`. + * + * The real chrome around Clean: AppShell at `clean` with the story's workspace + * facts. Clean has no tab bar; the screen fills the rest without page scroll. + */ +const withCleanChrome: Decorator = (Story, { parameters }) => { + const { + badges = { collect: ['4 recordings'] }, + nextArea = 'clean', + } = parameters as ChromeParameters; + return ( + <MemoryRouter> + <AppShell + location="clean" + workspace={{ + name: WORKSPACE_TITLE, + experimentType: 'Faces/Houses', + modality: 'eeg', + }} + device="connected" + deviceName="Muse 2" + badges={badges} + nextArea={nextArea} + > + <Story /> + </AppShell> + </MemoryRouter> + ); +}; + +const meta: Meta = { + title: 'Domain/Clean', + parameters: { layout: 'fullscreen' }, + decorators: [withCleanChrome], +}; +export default meta; +type Story = StoryObj; + +/** Dataset selection with local state; the reveal and delete confirm are live. */ +function SelectHarness({ + recordings, + initialShowIncomplete = false, +}: { + recordings: RawRecording[]; + initialShowIncomplete?: boolean; +}) { + const [selected, setSelected] = useState<string | null>(recordings[0].key); + const [showIncomplete, setShowIncomplete] = useState(initialShowIncomplete); + const [deleting, setDeleting] = useState<RawRecording | null>(null); + return ( + <CleanDatasetSelect + recordings={recordings} + selected={selected} + onSelectChange={setSelected} + showIncomplete={showIncomplete} + onShowIncompleteChange={setShowIncomplete} + deletingRecording={deleting} + onDeleteRequest={setDeleting} + onDeleteConfirm={() => setDeleting(null)} + onDeleteCancel={() => setDeleting(null)} + onStart={fn()} + /> + ); +} + +interface ReviewHarnessProps { + status?: 'ready' | 'loading' | 'no-epochs'; + rejected?: number[]; + badChannels?: string[]; + suggestions?: CleanSuggestion[]; + saveState?: SaveState; + confirm?: CleanConfirm | null; + primerOpen?: boolean; + primerStep?: PrimerStep; +} + +/** Review with local state: exclusion clicks, suggestions, save and dialogs are live. */ +function ReviewHarness({ + status = 'ready', + rejected = [], + badChannels = [], + suggestions = [], + saveState = 'idle', + confirm = null, + primerOpen = false, + primerStep = 1, +}: ReviewHarnessProps) { + const [rejectedSet, setRejectedSet] = useState( + () => + new Set([ + ...rejected, + // An accepted suggestion is excluded — the sets never disagree. + ...suggestions.filter((s) => s.accepted).map((s) => s.index), + ]) + ); + const [badChannelSet, setBadChannelSet] = useState(() => new Set(badChannels)); + const [suggestionState, setSuggestionState] = useState(suggestions); + const [threshold, setThreshold] = useState(100); + const [save, setSave] = useState(saveState); + const [dialog, setDialog] = useState<CleanConfirm | null>(confirm); + const [primer, setPrimer] = useState(primerOpen); + const [step, setStep] = useState<PrimerStep>(primerStep); + return ( + <CleanReview + dataset={{ subject: 'P01', recording: 'P01-A-1-raw.csv' }} + status={status} + epochArrays={status === 'ready' ? EXAMPLE_EPOCH_ARRAYS : null} + codeToLabel={FACES_HOUSES_CODE_TO_LABEL} + rejected={rejectedSet} + badChannels={badChannelSet} + onToggleEpoch={(index) => { + setPrimer(false); + setRejectedSet((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }} + onToggleChannel={(name) => { + setPrimer(false); + const next = new Set(badChannelSet); + const adding = !next.has(name); + if (adding) { + next.add(name); + } else { + next.delete(name); + } + setBadChannelSet(next); + // Dropping more than one of four sensors is informational (existing + // behavior): the flag applies either way, the dialog just warns. + if (adding && next.size > 1) { + setDialog('dropChannels'); + } + }} + autoFlagThreshold={threshold} + onThresholdChange={setThreshold} + suggestions={suggestionState} + onAcceptSuggestion={(index) => { + setPrimer(false); + setRejectedSet((prev) => new Set(prev).add(index)); + setSuggestionState((prev) => + prev.map((s) => (s.index === index ? { ...s, accepted: true } : s)) + ); + }} + onRestoreSuggestion={(index) => { + setPrimer(false); + setRejectedSet((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + setSuggestionState((prev) => + prev.map((s) => (s.index === index ? { ...s, accepted: false } : s)) + ); + }} + onSuggest={() => + setSuggestionState( + SUGGESTED_REJECTIONS.map((s) => ({ ...s, accepted: false })) + ) + } + saveState={save} + onApply={() => { + if (rejectedSet.size >= EXAMPLE_EPOCH_ARRAYS.meta.n_epochs) { + setDialog('rejectAll'); + } else { + setSave('saving'); + } + }} + onSave={() => { + if (rejectedSet.size >= EXAMPLE_EPOCH_ARRAYS.meta.n_epochs) { + setDialog('rejectAll'); + } else if (rejectedSet.size > 0) { + setDialog('removeSelected'); + } else if (badChannelSet.size > 0) { + setDialog('applyChannels'); + } else { + setSave('saving'); + } + }} + onRetrySave={() => setSave('saving')} + onGoToAnalyze={fn()} + onGoToCollect={fn()} + onBackToSelection={fn()} + confirm={dialog} + onConfirmAccept={() => { + setDialog(null); + setSave('saving'); + }} + onConfirmCancel={() => setDialog(null)} + primerOpen={primer} + primerStep={step} + onPrimerOpenChange={setPrimer} + onPrimerStepChange={setStep} + /> + ); +} + +/** C01 — Pick a complete raw recording: ordinary list only, one primary Start cleaning. */ +export const DatasetSelect: Story = { + render: () => ( + <SelectHarness + recordings={RAW_RECORDINGS.filter((recording) => !recording.incomplete)} + /> + ), +}; + +/** C02 — Ended-early recordings hidden by default; reveal marks them incomplete and deletes with a confirm. */ +export const DatasetSelectWithIncomplete: Story = { + render: () => <SelectHarness recordings={RAW_RECORDINGS} />, +}; + +/** C03 — Loading epochs, said out loud; the rail stays usable. */ +export const Loading: Story = { + render: () => <ReviewHarness status="loading" />, +}; + +/** C04 — The recording produced no usable epochs: why, and what to do next. */ +export const NoEpochs: Story = { + render: () => <ReviewHarness status="no-epochs" />, +}; + +/** C05 — First view of review: the compact primer teaching the cleaning loop, pointing at the reviewer. */ +export const Primer: Story = { + render: () => <ReviewHarness primerOpen primerStep={1} />, +}; + +/** C06 — Review: the Epoch Reviewer and the Live ERP as a coordinated pair, with the controls rail. */ +export const Review: Story = { + render: () => <ReviewHarness />, +}; + +/** C07 — Four trials left out and one sensor flagged: the Live ERP is visibly cleaner, counts in words. */ +export const ReviewWithSelections: Story = { + render: () => ( + <ReviewHarness + rejected={[2, 5, 11, 19]} + badChannels={['AF7']} + suggestions={SUGGESTED_REJECTIONS.map((s) => ({ + ...s, + accepted: s.index === 3, + }))} + /> + ), +}; + +/** C08 — Auto-flag output as suggestions: distinct from your own exclusions, with accept and restore. */ +export const AutoFlagSuggestions: Story = { + render: () => ( + <ReviewHarness + suggestions={SUGGESTED_REJECTIONS.map((s) => ({ + ...s, + accepted: s.index === 3, + }))} + /> + ), +}; + +/** C09 — The reject-all confirmation, restyled as a dialog with a destructive confirm. */ +export const ConfirmRejectAll: Story = { + render: () => ( + <ReviewHarness + rejected={Array.from( + { length: EXAMPLE_EPOCH_ARRAYS.meta.n_epochs }, + (_, i) => i + )} + confirm="rejectAll" + /> + ), +}; + +/** C10 — Flagging more than one of four sensors: the drop-channels caution, restyled. */ +export const ConfirmDropChannels: Story = { + render: () => ( + <ReviewHarness badChannels={['AF7', 'AF8']} confirm="dropChannels" /> + ), +}; + +/** C11 — Save in progress, said in words; the original recording is unchanged. */ +export const Saving: Story = { + render: () => <ReviewHarness rejected={[2, 5]} saveState="saving" />, +}; + +/** C12 — Save failed: nothing written, with Try again. */ +export const SaveFailed: Story = { + render: () => <ReviewHarness rejected={[2, 5]} saveState="failed" />, +}; + +/** C13 — Saved: success in words, pointing to Analyze as the next step. */ +export const Saved: Story = { + parameters: { + badges: { collect: ['4 recordings'], clean: ['1 cleaned'] }, + nextArea: 'analyze', + }, + render: () => <ReviewHarness rejected={[2, 5]} saveState="saved" />, +}; diff --git a/src/renderer/components/Clean/CleanDatasetSelect.tsx b/src/renderer/components/Clean/CleanDatasetSelect.tsx new file mode 100644 index 00000000..dac050ce --- /dev/null +++ b/src/renderer/components/Clean/CleanDatasetSelect.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { RailSection, railLabel } from '../Analyze/AnalyzeParts'; +import { CleanLayout, ConfirmDialog } from './CleanParts'; +import { CLEAN_DEFINITION } from './CleanPrimer'; +import type { RawRecording } from './fixtures'; + +export interface CleanDatasetSelectProps { + recordings: RawRecording[]; + /** The one recording chosen for cleaning — Clean loads a single recording. */ + selected: string | null; + onSelectChange(key: string): void; + /** Ended-early recordings stay hidden until the student asks for them. */ + showIncomplete: boolean; + onShowIncompleteChange(show: boolean): void; + /** Ended-early recording pending a confirmed delete; null closes the dialog. */ + deletingRecording: RawRecording | null; + onDeleteRequest(recording: RawRecording): void; + onDeleteConfirm(): void; + onDeleteCancel(): void; + onStart(): void; +} + +/** The §8.2 loop in one line each, numbers written as text (global `li` reset). */ +const LOOP = [ + 'Leave out noisy trials by clicking them.', + 'Flag a sensor that looks bad the whole way through.', + 'Check the auto-flag suggestions — they are only suggestions.', + 'Watch the Live ERP clean up as you go.', + 'Save the cleaned copy and continue to Analyze.', +]; + +/** + * Clean's first phase: pick one complete raw recording to clean. Ended-early + * recordings are hidden by default and, once revealed, are clearly incomplete + * and deletable but never selectable as cleaning candidates. Pure props. + */ +export default function CleanDatasetSelect({ + recordings, + selected, + onSelectChange, + showIncomplete, + onShowIncompleteChange, + deletingRecording, + onDeleteRequest, + onDeleteConfirm, + onDeleteCancel, + onStart, +}: CleanDatasetSelectProps) { + const complete = recordings.filter((r) => !r.incomplete); + const incomplete = recordings.filter((r) => r.incomplete); + const chosen = recordings.find((r) => r.key === selected) ?? null; + + const rail = ( + <> + <RailSection label="What cleaning does"> + <div className="text-[13px] leading-[1.45] text-ink-muted"> + {CLEAN_DEFINITION} + </div> + </RailSection> + <RailSection label="What you'll do" className="border-t border-gray-200 pt-[12px]"> + <ol className="m-0 flex flex-col gap-[4px] p-0 text-[13px] leading-[1.4] text-ink"> + {LOOP.map((line, i) => ( + <li key={line}> + <span className="font-bold text-ink-muted">{i + 1}.</span> {line} + </li> + ))} + </ol> + </RailSection> + <RailSection label="Your pick" className="border-t border-gray-200 pt-[12px]"> + <div className="text-[14px] text-ink"> + {chosen ? ( + <> + <div className="font-bold">{chosen.subject}</div> + <div className="truncate text-ink-muted">{chosen.name}</div> + </> + ) : ( + 'Nothing chosen yet.' + )} + </div> + <Button + className="mt-[4px] w-full" + size="lg" + disabled={chosen === null} + onClick={onStart} + > + Start cleaning + </Button> + </RailSection> + </> + ); + + return ( + <CleanLayout title="Clean your data" rail={rail}> + <section className="flex min-h-0 flex-1 flex-col rounded-lg border border-gray-200 bg-white p-[24px]"> + <h2 className="m-0 text-[22px] font-light text-ink">Clean your data</h2> + <div className="mt-[4px] text-[15px] leading-[1.5] text-ink-muted"> + Choose a complete raw recording. Cleaning saves a new copy and never + changes the original. + </div> + + <div className={`${railLabel} mt-[16px]`}>Complete recordings</div> + <ul + role="radiogroup" + aria-label="Complete recordings" + className="m-0 mt-[6px] flex flex-col gap-[6px] p-0" + > + {complete.map((recording) => { + const checked = selected === recording.key; + return ( + <li key={recording.key}> + <label + className={cn( + 'flex cursor-pointer items-center gap-[12px] rounded-md border px-[14px] py-[10px]', + checked + ? 'border-brand bg-brand-light' + : 'border-gray-200 hover:border-brand' + )} + > + <input + type="radio" + name="clean-recording" + className="h-[16px] w-[16px] accent-brand" + checked={checked} + onChange={() => onSelectChange(recording.key)} + /> + <span className="font-bold text-ink">{recording.subject}</span> + <span className="text-[14px] text-ink">{recording.name}</span> + <span className="ml-auto text-[13px] text-ink-muted"> + {recording.duration} + </span> + </label> + </li> + ); + })} + </ul> + + {incomplete.length > 0 && ( + <div className="mt-[12px] flex items-center gap-[8px] text-[13px] text-ink-muted"> + {incomplete.length} ended-early recording + {incomplete.length === 1 ? '' : 's'}{' '} + {showIncomplete ? 'shown below' : 'hidden'} + <Button + variant="link" + size="sm" + onClick={() => onShowIncompleteChange(!showIncomplete)} + > + {showIncomplete ? 'Hide' : 'Show'} + </Button> + </div> + )} + + {showIncomplete && ( + <ul className="m-0 mt-[6px] flex flex-col gap-[6px] p-0"> + {incomplete.map((recording) => ( + <li + key={recording.key} + className="flex items-center gap-[12px] rounded-md border border-dashed border-gray-300 bg-gray-50 px-[14px] py-[10px]" + > + <span className="flex-none rounded-full border border-gray-300 px-[8px] py-[1px] text-[11px] font-bold uppercase tracking-[0.5px] text-ink-muted"> + Ended early + </span> + <span className="text-[14px] text-ink-muted"> + {recording.subject} · {recording.name} + </span> + <span className="text-[13px] text-ink-muted"> + Kept as incomplete data — not a cleaning candidate. + </span> + <Button + variant="destructive" + size="sm" + className="ml-auto flex-none" + onClick={() => onDeleteRequest(recording)} + > + Delete + </Button> + </li> + ))} + </ul> + )} + </section> + + <ConfirmDialog + open={deletingRecording !== null} + title="Delete this ended-early recording?" + body={`${deletingRecording?.name ?? ''} will be deleted from your workspace. This can't be undone.`} + confirmLabel="Delete recording" + destructive + onConfirm={onDeleteConfirm} + onCancel={onDeleteCancel} + /> + </CleanLayout> + ); +} diff --git a/src/renderer/components/Clean/CleanParts.tsx b/src/renderer/components/Clean/CleanParts.tsx new file mode 100644 index 00000000..54952b18 --- /dev/null +++ b/src/renderer/components/Clean/CleanParts.tsx @@ -0,0 +1,133 @@ +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; + +/** + * Clean screen body: a fixed-width controls rail on the left and the working + * area on the right, sized so both fit the window without page scroll — the + * Analyze layout, so Clean and Analyze read as one family. + */ +export function CleanLayout({ + title, + rail, + children, +}: { + /** Screen-reader heading for the screen. */ + title: string; + rail: ReactNode; + children: ReactNode; +}) { + return ( + <div className="flex h-full min-h-0 gap-[20px] px-[24px] py-[20px]"> + <h1 className="sr-only">{title}</h1> + <aside + aria-label="Cleaning controls" + className="flex w-[300px] flex-none flex-col gap-[5px] overflow-y-auto rounded-lg border border-gray-200 bg-white p-[14px]" + > + {rail} + </aside> + <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-[12px]"> + {children} + </div> + </div> + ); +} + +/** + * Scales a fixed-logical-size pane (`EpochReviewer`, `LiveErpPane`) to fill + * its box — `zoom`, so layout and click targets scale together — so the real + * panes use the space they are given instead of sitting at their hand-coded + * 640px width. + */ +export function FitPane({ + logicalWidth, + logicalHeight, + className, + children, +}: { + /** The pane's natural size in CSS px. */ + logicalWidth: number; + logicalHeight: number; + className?: string; + children: ReactNode; +}) { + const ref = useRef<HTMLDivElement>(null); + const [zoom, setZoom] = useState(1); + useEffect(() => { + const box = ref.current; + if (!box) { + return undefined; + } + const observer = new ResizeObserver(() => { + const { width, height } = box.getBoundingClientRect(); + if (width > 0 && height > 0) { + setZoom(Math.min(width / logicalWidth, height / logicalHeight)); + } + }); + observer.observe(box); + return () => observer.disconnect(); + }, [logicalWidth, logicalHeight]); + return ( + <div ref={ref} className={cn('min-h-0 min-w-0 overflow-hidden', className)}> + <div style={{ zoom, width: logicalWidth, height: logicalHeight }}> + {children} + </div> + </div> + ); +} + +/** + * One confirmation dialog for the whole screen family. Restyles the native + * `showMessageBox` confirmations of `CleanComponent` as in-app dialogs, so the + * wording and the two-button contract stay the same. + */ +export function ConfirmDialog({ + open, + title, + body, + confirmLabel, + destructive = false, + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + body: string; + /** The effect-bearing action, rightmost. `Cancel` is always the left button. */ + confirmLabel: string; + /** Red for actions that leave nothing behind (rejecting every trial, deleting). */ + destructive?: boolean; + onConfirm(): void; + onCancel(): void; +}) { + return ( + <Dialog open={open} onOpenChange={(next) => !next && onCancel()}> + <DialogContent> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription className="text-[15px] leading-[1.5] text-ink-muted"> + {body} + </DialogDescription> + </DialogHeader> + <div className="mt-4 flex justify-end gap-[8px]"> + <Button variant="secondary" onClick={onCancel}> + Cancel + </Button> + <Button + variant={destructive ? 'destructive' : 'default'} + onClick={onConfirm} + > + {confirmLabel} + </Button> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/renderer/components/Clean/CleanPrimer.tsx b/src/renderer/components/Clean/CleanPrimer.tsx new file mode 100644 index 00000000..aa771006 --- /dev/null +++ b/src/renderer/components/Clean/CleanPrimer.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useRef } from 'react'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { railLabel } from '../Analyze/AnalyzeParts'; + +/** The cleaning loop of plan §8.2, one step at a time. */ +export type PrimerStep = 1 | 2 | 3 | 4 | 5; + +/** The §8.1 definition, shown with every step. */ +export const CLEAN_DEFINITION = + 'Cleaning means finding trials or sensors with movement or poor signal and excluding them before the responses are averaged. Your original recording stays unchanged.'; + +/** The §8.2 loop in one line each, numbers written as text (global `li` reset). */ +export const CLEAN_LOOP = [ + 'Leave out noisy trials by clicking them.', + 'Flag a sensor that looks bad the whole way through.', + 'Check the auto-flag suggestions — they are only suggestions.', + 'Watch the Live ERP clean up as you go.', + 'Save the cleaned copy and continue to Analyze.', +]; + +const STEPS: PrimerStep[] = [1, 2, 3, 4, 5]; + +const PRIMER_COPY: Record< + PrimerStep, + { title: string; body: string; pointer: string } +> = { + 1: { + title: 'Leave out noisy trials', + body: 'Every column in the Epochs panel is one trial. Click a noisy one to leave it out — click it again to bring it back.', + pointer: 'Click a noisy trial column to leave it out', + }, + 2: { + title: 'Flag a bad sensor', + body: 'One row is one sensor. If one sensor looks bad the whole way through, click its name to leave it out too.', + pointer: 'Click a sensor name to flag it', + }, + 3: { + title: 'Check the suggestions', + body: 'Auto-flag can point out trials that look noisy. They collect under the Live ERP — they are only suggestions, so you decide.', + pointer: 'Suggestions collect under the Live ERP', + }, + 4: { + title: 'Watch the Live ERP', + body: 'The Live ERP under these trials updates as you leave trials out. As the noisy trials go, the waves get cleaner.', + pointer: 'This average updates as you clean', + }, + 5: { + title: 'Save and continue', + body: 'Save the cleaned dataset when you are happy. Analyze uses the cleaned copy to make your results; your original recording stays unchanged.', + pointer: 'Save is in the left panel', + }, +}; + +/** Where each step points over the Epochs panel (`left`/`top` in % of it). */ +const POINTER_PLACEMENT: Record< + PrimerStep, + { left: string; top: string; arrow: string } +> = { + 1: { left: '30%', top: '34%', arrow: '↓' }, + 2: { left: '0%', top: '56%', arrow: '→' }, + 3: { left: '58%', top: '90%', arrow: '↓' }, + 4: { left: '18%', top: '90%', arrow: '↓' }, + 5: { left: '0%', top: '30%', arrow: '←' }, +}; + +/** + * A callout chip over the Epochs panel showing which part of the screen the + * current primer step is about; it may overlap the canvas edge but never + * covers a pointer target or the Prev/Next controls. Decorative — the step + * panel carries the words. + */ +export function PrimerPointer({ step }: { step: PrimerStep }) { + const { left, top, arrow } = POINTER_PLACEMENT[step]; + return ( + <div + aria-hidden + className="pointer-events-none absolute z-10 flex max-w-[240px] items-center gap-[6px] rounded-md border-2 border-accent bg-white px-[10px] py-[6px] text-[13px] font-bold text-ink shadow-sm" + style={{ left, top }} + > + {arrow === '←' && <span>{arrow}</span>} + <span>{PRIMER_COPY[step].pointer}</span> + {arrow !== '←' && <span>{arrow}</span>} + </div> + ); +} + +/** + * The always-available cleaning primer, docked beside the Epochs panel so it + * never covers the reviewer. Collapsed it teaches the whole loop (§8.1 + * definition plus the §8.2 steps); expanded it walks through the steps one at + * a time in the walkthrough idiom from Analyze. No first-use flag. + */ +export function CleanPrimerPanel({ + open, + step, + onStepChange, + onOpenChange, + className, +}: { + open: boolean; + step: PrimerStep; + onStepChange(step: PrimerStep): void; + onOpenChange(open: boolean): void; + className?: string; +}) { + const heading = useRef<HTMLHeadingElement>(null); + const mounted = useRef(false); + useEffect(() => { + if (mounted.current) heading.current?.focus(); + mounted.current = true; + }, [step]); + + return ( + <section + aria-label="How cleaning works" + className={cn( + 'flex flex-col gap-[8px] rounded-lg border border-gray-200 bg-white p-[16px]', + className + )} + > + {!open ? ( + <> + <div className={railLabel}>How cleaning works</div> + <div className="text-[13px] leading-[1.45] text-ink-muted"> + {CLEAN_DEFINITION} + </div> + <ol className="m-0 flex flex-col gap-[5px] p-0 text-[13px] leading-[1.4] text-ink"> + {CLEAN_LOOP.map((line, i) => ( + <li key={line}> + <span className="font-bold text-ink-muted">{i + 1}.</span>{' '} + {line} + </li> + ))} + </ol> + <Button + variant="outline-brand" + size="sm" + className="mt-auto w-full" + onClick={() => onOpenChange(true)} + > + Show steps + </Button> + </> + ) : ( + <> + <div className="flex items-center gap-[10px]"> + <span className={railLabel} role="status"> + How cleaning works · Step {step} of 5 + </span> + <span className="flex gap-[5px]" aria-hidden> + {STEPS.map((s) => ( + <span + key={s} + className={cn( + 'h-[6px] w-[18px] rounded-full', + s === step + ? 'bg-accent' + : s < step + ? 'bg-accent-light' + : 'bg-ink-faint' + )} + /> + ))} + </span> + <Button + variant="ghost" + size="sm" + className="ml-auto flex-none text-ink-muted" + onClick={() => onOpenChange(false)} + > + Hide ✕ + </Button> + </div> + <h2 + ref={heading} + tabIndex={-1} + className="m-0 text-[20px] font-light leading-tight text-ink outline-none" + > + {PRIMER_COPY[step].title} + </h2> + <div className="text-[14px] leading-[1.5] text-ink"> + {PRIMER_COPY[step].body} + </div> + <div className="text-[12px] leading-[1.45] text-ink-muted"> + {CLEAN_DEFINITION} + </div> + <div className="mt-auto flex justify-end gap-[8px]"> + <Button + variant="outline-brand" + size="sm" + disabled={step === 1} + onClick={() => onStepChange((step - 1) as PrimerStep)} + > + Back + </Button> + <Button + size="sm" + onClick={() => + step === 5 + ? onOpenChange(false) + : onStepChange((step + 1) as PrimerStep) + } + > + {step === 5 ? 'Finish' : 'Next'} + </Button> + </div> + </> + )} + </section> + ); +} diff --git a/src/renderer/components/Clean/CleanReview.tsx b/src/renderer/components/Clean/CleanReview.tsx new file mode 100644 index 00000000..bf0492ec --- /dev/null +++ b/src/renderer/components/Clean/CleanReview.tsx @@ -0,0 +1,387 @@ +import React from 'react'; +import type { SuggestedRejection } from '../../actions'; +import { PTP_THRESHOLD } from '../../constants/constants'; +import EpochReviewer from '../CleanComponent/EpochReviewer'; +import LiveErpPane from '../CleanComponent/LiveErpPane'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import { RailSection, ResultStatus, railLabel } from '../Analyze/AnalyzeParts'; +import { CleanLayout, ConfirmDialog, FitPane } from './CleanParts'; +import { + CleanPrimerPanel, + PrimerPointer, + PrimerStep, +} from './CleanPrimer'; +import type { EpochArrays } from './fixtures'; + +/** Which `CleanComponent` confirmation is open, restyled as an in-app dialog. */ +export type CleanConfirm = + | 'rejectAll' + | 'removeSelected' + | 'applyChannels' + | 'dropChannels'; + +/** One auto-flag suggestion with its review state. */ +export interface CleanSuggestion extends SuggestedRejection { + /** Accepted suggestions are excluded like the student's own clicks. */ + accepted: boolean; +} + +/** Where the save stands. `idle` shows the two save actions. */ +export type SaveState = 'idle' | 'saving' | 'failed' | 'saved'; + +export interface CleanReviewProps { + /** Where the epochs came from, for the rail and the status copy. */ + dataset: { subject: string; recording: string }; + /** Epochs as `pyodide.epochArrays` holds them; null while loading. */ + epochArrays: EpochArrays | null; + /** `loading` and `no-epochs` replace the review area; the rail stays usable. */ + status: 'ready' | 'loading' | 'no-epochs'; + codeToLabel: Record<number, string>; + /** ABSOLUTE epoch indices left out, including accepted suggestions. */ + rejected: Set<number>; + badChannels: Set<string>; + onToggleEpoch(index: number): void; + onToggleChannel(name: string): void; + autoFlagThreshold: number; + onThresholdChange(value: number): void; + suggestions: CleanSuggestion[]; + onAcceptSuggestion(index: number): void; + onRestoreSuggestion(index: number): void; + onSuggest(): void; + saveState: SaveState; + onApply(): void; + onSave(): void; + onRetrySave(): void; + onGoToAnalyze(): void; + onGoToCollect(): void; + onBackToSelection(): void; + confirm: CleanConfirm | null; + onConfirmAccept(): void; + onConfirmCancel(): void; + primerOpen: boolean; + primerStep: PrimerStep; + onPrimerOpenChange(open: boolean): void; + onPrimerStepChange(step: PrimerStep): void; +} + +/** + * Clean's review phase. The Epoch Reviewer fills the top of the working area + * — it is the thing students click — with the primer docked beside it (never + * over it) and a bottom row holding a legible Live ERP next to the auto-flag + * suggestions it feeds. Everything fits the window with no page scroll. Pure + * props; the real reviewer and ERP panes are rendered unmodified inside + * `FitPane`. + */ +export default function CleanReview(props: CleanReviewProps) { + const meta = props.epochArrays?.meta ?? null; + const total = meta?.n_epochs ?? 0; + const acceptedCount = props.suggestions.filter((s) => s.accepted).length; + const kept = total - props.rejected.size; + const { dataset } = props; + + const rail = ( + <> + <RailSection label="Dataset"> + <div className="text-[13px] leading-[1.35]"> + <div className="font-bold text-ink">{dataset.subject}</div> + <div className="text-ink-muted">{dataset.recording}</div> + </div> + <Button variant="ghost" size="sm" onClick={props.onBackToSelection}> + ← Pick different data + </Button> + </RailSection> + <RailSection label="Auto-flag" className="border-t border-gray-200 pt-[10px]"> + <div className="flex items-center gap-[8px]"> + <span className="text-[11px] text-ink-muted">More flags</span> + <input + id="clean-autoflag-threshold" + type="range" + min={PTP_THRESHOLD.min} + max={PTP_THRESHOLD.max} + step={PTP_THRESHOLD.step} + value={props.autoFlagThreshold} + aria-valuetext={`${props.autoFlagThreshold} µV peak-to-peak`} + onChange={(e) => props.onThresholdChange(Number(e.target.value))} + className="flex-1 accent-brand" + /> + <span className="text-[11px] text-ink-muted">Fewer</span> + </div> + <div className="text-[11px] leading-[1.4] text-ink-muted"> + Suggests trials whose peak-to-peak amplitude goes over{' '} + {props.autoFlagThreshold} µV. + </div> + <Button variant="outline-brand" size="sm" onClick={props.onSuggest}> + Suggest noisy trials + </Button> + </RailSection> + <RailSection label="Exclusions" className="border-t border-gray-200 pt-[10px]"> + {total === 0 ? ( + <div className="text-[13px] text-ink-muted"> + Counts show up once the trials are loaded. + </div> + ) : ( + <div className="text-[13px] leading-[1.35] text-ink"> + <div> + {props.rejected.size} of {total} trials left out + {props.rejected.size > 0 && + (acceptedCount > 0 && props.rejected.size > acceptedCount + ? ` (${props.rejected.size - acceptedCount} by you, ${acceptedCount} suggested)` + : acceptedCount > 0 + ? ' (all suggested)' + : ' (all by you)')} + </div> + <div> + {props.badChannels.size === 0 + ? 'No sensors flagged' + : `Sensor${props.badChannels.size === 1 ? '' : 's'} flagged: ${[ + ...props.badChannels, + ].join(', ')}`} + </div> + <div className="font-bold">{kept} trials will be averaged</div> + </div> + )} + </RailSection> + <RailSection label="Save" className="border-t border-gray-200 pt-[10px]"> + {props.saveState === 'saving' && ( + <div role="status" className="flex items-center gap-[8px]"> + <Spinner size={20} aria-hidden /> + <div> + <div className="text-[13px] font-bold text-ink"> + Saving your cleaned dataset… + </div> + <div className="text-[11px] leading-[1.35] text-ink-muted"> + Writing a cleaned copy. Your original recording is unchanged. + </div> + </div> + </div> + )} + {props.saveState === 'failed' && ( + <div role="alert"> + <div className="text-[13px] font-bold text-red-700"> + Couldn't save the cleaned dataset + </div> + <div className="mt-[2px] text-[11px] leading-[1.35] text-ink-muted"> + Nothing was written — your original recording is unchanged. + </div> + <div className="mt-[6px] flex flex-col gap-[6px]"> + <Button size="sm" onClick={props.onRetrySave}> + Try again + </Button> + <Button size="sm" variant="outline-brand" onClick={props.onApply}> + Apply exclusions + </Button> + </div> + </div> + )} + {props.saveState === 'saved' && ( + <div role="status"> + <div className="text-[13px] font-bold text-ink"> + ✓ Cleaned dataset saved + </div> + <div className="mt-[2px] text-[11px] leading-[1.35] text-ink-muted"> + Your original recording is unchanged. The cleaned copy is ready to + use in Analyze. + </div> + <Button size="sm" className="mt-[6px] w-full" onClick={props.onGoToAnalyze}> + Go to Analyze → + </Button> + </div> + )} + {props.saveState === 'idle' && ( + <div className="flex flex-col gap-[6px]"> + <Button size="sm" onClick={props.onSave}> + Save cleaned dataset & analyze + </Button> + <Button size="sm" variant="outline-brand" onClick={props.onApply}> + Apply exclusions + </Button> + </div> + )} + </RailSection> + </> + ); + + let body: React.ReactNode; + if (props.status === 'loading') { + body = ( + <ResultStatus + status="loading" + title={`Loading ${dataset.recording}…`} + body="Cutting the recording into trials so you can review them. This takes a few seconds." + /> + ); + } else if (props.status === 'no-epochs') { + body = ( + <div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-[12px] rounded-lg border border-gray-200 bg-white p-[32px]"> + <h2 className="m-0 text-[24px] font-light text-ink"> + No trials to clean in this recording + </h2> + <div className="max-w-[560px] text-[16px] leading-[1.5] text-ink-muted"> + {dataset.recording} has no usable trials. That usually means the + experiment ended before any stimulus appeared, or the trial markers + were missing. Your original recording is unchanged. + </div> + <div className="flex gap-[8px]"> + <Button size="lg" onClick={props.onBackToSelection}> + ← Pick different data + </Button> + <Button size="lg" variant="outline-brand" onClick={props.onGoToCollect}> + Go to Collect + </Button> + </div> + </div> + ); + } else { + body = ( + <> + <div className="flex min-h-0 flex-1 gap-[12px]"> + <div className="relative flex min-w-0 flex-1"> + <FitPane logicalWidth={640} logicalHeight={426} className="flex-1"> + <EpochReviewer + epochArrays={props.epochArrays} + rejected={props.rejected} + onToggleEpoch={props.onToggleEpoch} + badChannels={props.badChannels} + onToggleChannel={props.onToggleChannel} + codeToLabel={props.codeToLabel} + /> + </FitPane> + {props.primerOpen && <PrimerPointer step={props.primerStep} />} + </div> + <CleanPrimerPanel + open={props.primerOpen} + step={props.primerStep} + onStepChange={props.onPrimerStepChange} + onOpenChange={props.onPrimerOpenChange} + className="w-[300px] flex-none" + /> + </div> + <div className="flex h-[224px] flex-none gap-[12px]"> + <section + aria-label="Live ERP" + className={ + props.suggestions.length > 0 + ? 'flex w-[440px] flex-none flex-col rounded-lg border border-gray-200 bg-white px-[12px] pb-[8px] pt-[6px]' + : 'flex flex-1 flex-col rounded-lg border border-gray-200 bg-white px-[12px] pb-[8px] pt-[6px]' + } + > + <FitPane + logicalWidth={640} + logicalHeight={344} + className="flex min-h-0 w-full flex-1 items-center justify-center" + > + <LiveErpPane + epochArrays={props.epochArrays} + rejected={props.rejected} + codeToLabel={props.codeToLabel} + /> + </FitPane> + </section> + {props.suggestions.length > 0 && ( + <section + aria-label="Auto-flag suggestions" + className="flex min-w-0 flex-1 flex-col gap-[6px] rounded-lg border border-gray-200 bg-white p-[14px]" + > + <div className="flex items-baseline gap-[10px]"> + <span className={railLabel}>Suggested by auto-flag</span> + <div className="text-[12px] text-ink-muted"> + Suggestions, not decisions — you decide. + </div> + </div> + <ul className="m-0 flex flex-col gap-[4px] p-0"> + {props.suggestions.map((suggestion) => ( + <li + key={suggestion.index} + className="flex items-center gap-[10px] rounded-md border border-gray-200 px-[10px] py-[4px]" + > + <span className="flex-none text-[13px] font-bold text-ink"> + Trial {suggestion.index} + </span> + <span className="min-w-0 flex-1 truncate text-[13px] text-ink-muted"> + {suggestion.reason} + </span> + {suggestion.accepted ? ( + <> + <span className="flex-none text-[12px] font-bold text-ink"> + ✓ Left out (from a suggestion) + </span> + <Button + size="sm" + variant="outline" + className="flex-none" + onClick={() => + props.onRestoreSuggestion(suggestion.index) + } + > + Restore + </Button> + </> + ) : ( + <Button + size="sm" + variant="outline-brand" + className="flex-none" + onClick={() => + props.onAcceptSuggestion(suggestion.index) + } + > + Accept + </Button> + )} + </li> + ))} + </ul> + </section> + )} + </div> + </> + ); + } + + const confirmCopy: Record< + CleanConfirm, + { title: string; body: string; confirmLabel: string; destructive: boolean } + > = { + rejectAll: { + title: 'Leave out every trial?', + body: `This will reject all ${total} epochs, leaving nothing to analyze. Are you sure?`, + confirmLabel: 'Reject all anyway', + destructive: true, + }, + removeSelected: { + title: 'Remove the selected trials?', + body: `This will remove ${props.rejected.size} selected epoch${props.rejected.size === 1 ? '' : 's'} before analysis. Continue?`, + confirmLabel: 'Remove selected and analyze', + destructive: false, + }, + applyChannels: { + title: 'Apply the flagged sensors?', + body: 'This will apply flagged bad channels before analysis. Continue?', + confirmLabel: 'Apply and analyze', + destructive: false, + }, + dropChannels: { + title: 'More than one bad sensor flagged', + body: "You've marked more than one bad channel on a 4-channel recording. That removes a big chunk of your data — if the signal is really this noisy, consider collecting another dataset.", + confirmLabel: 'Got it', + destructive: false, + }, + }; + const dialog = props.confirm ? confirmCopy[props.confirm] : null; + + return ( + <CleanLayout title="Clean your data — review" rail={rail}> + {body} + <ConfirmDialog + open={dialog !== null} + title={dialog?.title ?? ''} + body={dialog?.body ?? ''} + confirmLabel={dialog?.confirmLabel ?? ''} + destructive={dialog?.destructive} + onConfirm={props.onConfirmAccept} + onCancel={props.onConfirmCancel} + /> + </CleanLayout> + ); +} diff --git a/src/renderer/components/Clean/fixtures.ts b/src/renderer/components/Clean/fixtures.ts new file mode 100644 index 00000000..01f67ed8 --- /dev/null +++ b/src/renderer/components/Clean/fixtures.ts @@ -0,0 +1,76 @@ +import type { SuggestedRejection } from '../../actions'; + +export { + EXAMPLE_EPOCH_ARRAYS, + FACES_HOUSES_CODE_TO_LABEL, + MUSE_CHANNEL_INFO, + WORKSPACE_TITLE, +} from '../Analyze/fixtures'; +export type { EpochArrays } from '../Analyze/fixtures'; + +/** A raw EEG recording as `readWorkspaceRawEEGData` lists it (`<subject>-<group>-<session>-raw.csv`). */ +export interface RawRecording { + key: string; + subject: string; + /** File name shown in the list. */ + name: string; + /** How long the run lasted. */ + duration: string; + /** Ended-early runs are renamed `*.incomplete.csv` and hidden from ordinary selection. */ + incomplete: boolean; +} + +/** + * Example workspace recordings: three complete runs Clean may offer, and two + * ended-early runs (#275) kept as incomplete data but never cleaning + * candidates. + */ +export const RAW_RECORDINGS: RawRecording[] = [ + { + key: 'P01-A-1', + subject: 'P01', + name: 'P01-A-1-raw.csv', + duration: '4 min · 84 trials', + incomplete: false, + }, + { + key: 'P01-A-2', + subject: 'P01', + name: 'P01-A-2-raw.csv', + duration: '4 min · 84 trials', + incomplete: false, + }, + { + key: 'P02-A-1', + subject: 'P02', + name: 'P02-A-1-raw.csv', + duration: '3 min · 62 trials', + incomplete: false, + }, + { + key: 'P02-A-2', + subject: 'P02', + name: 'P02-A-2-raw.incomplete.csv', + duration: '2 min · experiment ended early', + incomplete: true, + }, + { + key: 'P03-A-1', + subject: 'P03', + name: 'P03-A-1-raw.incomplete.csv', + duration: '30 s · experiment ended early', + incomplete: true, + }, +]; + +/** + * Auto-flag output for the example epochs, in the shape Python's + * `suggest_rejections` returns: one artifact suggestion per noisy trial. + * Suggestions are never applied on their own — the student accepts them. + * Reasons are short enough for the rail's one-line rows. + */ +export const SUGGESTED_REJECTIONS: SuggestedRejection[] = [ + { index: 3, reason: '212 µV peak-to-peak at AF7' }, + { index: 12, reason: '189 µV peak-to-peak at TP9' }, + { index: 27, reason: '176 µV peak-to-peak at AF7' }, +];