Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .llms/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ Redux, so it never lands in the persisted `appState.json`.
## Global `li { list-style: none }` hides `list-decimal`/`list-disc`

`app.global.css` resets `li` unlayered, so Tailwind list utilities on `<ol>`/`<li>`
lose and numbered lists render bare. Write the numbers as text (see
`CleanExplainer` in `CleanComponent/index.tsx`) or add a scoped class.
lose and numbered lists render bare. Write the numbers as text (see the
loop list in `Clean/CleanDatasetSelect.tsx`) or add a scoped class.

## Headset setup: discovery is time-limited and gesture-bound

Expand Down Expand Up @@ -447,6 +447,10 @@ auto-answered by re-registering `dialog:showMessage` from that inspector
`dialog.showSaveDialog` over the main-process inspector to drive Export.
- Browser-tool clicks can leave `:hover` styles stuck on clicked tabs in
screenshots; `element.click()` via `evaluate` avoids it.
- The first visit to Collect can trigger Vite's "optimized dependencies
changed. reloading": Redux resets but the hash route stays on `/collect`, so a
run started then has `title: ''` and writes to `~/BrainWaves_Workspaces/Data/`.
Reopen the workspace from Home after any dev reload before recording.

## Prepare: `PrepareSteps` owns the built-in step chrome

Expand Down
12 changes: 7 additions & 5 deletions docs/user-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,13 @@ Custom experiments add Conditions / Trials / Parameters / Instructions. Pick 1

Shown when EEG is enabled.

1. Select a subject and one or more recordings.
2. **Load Dataset** — Pyodide epochs + interactive `EpochReviewer` / `LiveErpPane`.
3. **Clean Data** — reject artifacts, write `.fif`. **Analyze Dataset** is available once `epochsInfo` exists; it is not gated on a drop-percentage threshold.
Pick **one** complete raw recording (ended-early recordings stay hidden until revealed, and can only be deleted to the Trash) → review its trials in `EpochReviewer` / `LiveErpPane` → leave out trials, flag sensors, and **Accept** or **Restore** each auto-flag suggestion (nothing is applied automatically) → **Apply exclusions** saves in place, and **Save cleaned dataset & analyze** always saves before opening Analyze.

### 5. Analyze (`/analyze`)

- **EEG mode** — topoplot (Pyodide/matplotlib SVG), ERP waveforms, behavioral plots (Plotly).
- **Behavior-only mode** — RT / accuracy (bar, box, scatter), outlier removal, export.
Tabs follow the EEG toggle:
- **Overview** (EEG): cleaned-recording checklist, an "Included" summary, and PSD and topography side by side.
- **ERP** (EEG): head-diagram sensor picker, the MNE ERP figure, and the "Walk me through it" walkthrough, drawn from the cleaned epochs.
- **Behavior** (always): response time or accuracy as bars, dots or box plots, outlier removal, and summary CSV export.

EEG tabs show "Clean first" until a cleaned recording exists. Behavior never gates.
77 changes: 73 additions & 4 deletions src/main/__tests__/recordings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
incompleteRecordingFiles,
isBehaviorFile,
isIncompleteRawEEGFile,
isRawEEGFile,
markRecordingIncomplete,
recordingExists,
} from '../recordings';

let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-'));
dir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-'))
);
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

Expand All @@ -20,10 +24,11 @@ const write = (rel: string) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'x');
};
/** Workspace CSVs relative to `dir`, with `/` separators (tests alias `path` to `pathe`). */
const csvFiles = () =>
(fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) =>
f.endsWith('.csv')
);
(fs.readdirSync(dir, { recursive: true }) as string[])
.map(path.normalize)
.filter((f) => f.endsWith('.csv'));

describe('recordings', () => {
it('complete recordings are discovered', () => {
Expand Down Expand Up @@ -55,4 +60,68 @@ describe('recordings', () => {
expect(csvFiles().filter(isBehaviorFile)).toEqual([]);
expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true);
});

it('an ended-early run is listed as incomplete and deletes with its behavior and events siblings', () => {
write('Data/P1/Behavior/P1-A-1-behavior.csv');
write('Data/P1/EEG/P1-A-1-raw.csv');
write('Data/P1/EEG/P1-A-1-events.json');

markRecordingIncomplete(dir, 'P1', 'A', 1);

const eeg = path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv');
expect(csvFiles().filter(isIncompleteRawEEGFile)).toEqual([
path.join('Data/P1/EEG/P1-A-1-raw.incomplete.csv'),
]);
expect(incompleteRecordingFiles(dir, eeg).map(path.normalize)).toEqual([
eeg,
path.join(dir, 'Data/P1/Behavior/P1-A-1-behavior.incomplete.csv'),
path.join(dir, 'Data/P1/EEG/P1-A-1-events.json'),
]);
});

it('refuses to delete anything but an ended-early EEG file inside Data', () => {
write('Data/P1/EEG/P1-A-1-raw.csv');
write('Other/P1-A-1-raw.incomplete.csv');

expect(() =>
incompleteRecordingFiles(
dir,
path.join(dir, 'Data/P1/EEG/P1-A-1-raw.csv')
)
).toThrow();
expect(() =>
incompleteRecordingFiles(
dir,
path.join(dir, 'Other/P1-A-1-raw.incomplete.csv')
)
).toThrow();
expect(() =>
incompleteRecordingFiles(
dir,
`${dir}/Data/../Other/P1-A-1-raw.incomplete.csv`
)
).toThrow();
});

it('refuses symlinks out of Data, the wrong depth, and directories', () => {
write('Other/P1-A-1-raw.incomplete.csv');
write('Data/P1-A-1-raw.incomplete.csv');
fs.mkdirSync(path.join(dir, 'Data/P1/EEG/P1-A-2-raw.incomplete.csv'), {
recursive: true,
});
fs.symlinkSync(
path.join(dir, 'Other/P1-A-1-raw.incomplete.csv'),
path.join(dir, 'Data/P1/EEG/P1-A-1-raw.incomplete.csv')
);

for (const rel of [
'Data/P1/EEG/P1-A-1-raw.incomplete.csv',
'Data/P1-A-1-raw.incomplete.csv',
'Data/P1/EEG/P1-A-2-raw.incomplete.csv',
]) {
expect(() =>
incompleteRecordingFiles(dir, path.join(dir, rel))
).toThrow();
}
});
});
43 changes: 41 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
} from '../shared/lslTypes';
import { importExperimentFile } from './importExperimentFile';
import {
incompleteRecordingFiles,
isBehaviorFile,
isIncompleteRawEEGFile,
isRawEEGFile,
markRecordingIncomplete,
recordingExists,
Expand Down Expand Up @@ -94,7 +96,7 @@
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
autoUpdater.checkForUpdatesAndNotify();

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 99 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
}

Expand All @@ -119,7 +121,13 @@
return stimulusFileAccess;
};

const getWorkspaceDir = (title: string) => path.join(workspaces, title);
/** A workspace's folder; `title` comes from the renderer, so it must be one path segment. */
const getWorkspaceDir = (title: string) => {
if (!title || title === '.' || title === '..' || title !== path.basename(title)) {

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`

Check warning on line 126 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `!title·||·title·===·'.'·||·title·===·'..'·||·title·!==·path.basename(title)` with `⏎····!title·||⏎····title·===·'.'·||⏎····title·===·'..'·||⏎····title·!==·path.basename(title)⏎··`
throw new Error(`Invalid workspace title: ${title}`);
}
return path.join(workspaces, title);
};

const mkdirPathSync = (dirPath: string) =>
fs.mkdirSync(dirPath, { recursive: true });
Expand Down Expand Up @@ -248,6 +256,24 @@
}
});

/** Ended-early raw EEG runs, which `fs:readWorkspaceRawEEGData` leaves out. */
ipcMain.handle('fs:readWorkspaceIncompleteEEGData', (_event, title) => {
try {
const files = fs.readdirSync(getWorkspaceDir(title), {
recursive: true,
}) as string[];
return files

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`

Check warning on line 265 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isIncompleteRawEEGFile)⏎······` with `.filter(isIncompleteRawEEGFile)`
.filter(isIncompleteRawEEGFile)
.map((filepath) => {
const fullPath = path.join(getWorkspaceDir(title), filepath);

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`

Check warning on line 268 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`
return { name: path.basename(filepath), path: fullPath };

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 269 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`
});

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 270 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') console.log(e);
return [];
}
});

ipcMain.handle('fs:readWorkspaceCleanedEEGData', (_event, title) => {
try {
const files = fs.readdirSync(getWorkspaceDir(title), {
Expand Down Expand Up @@ -352,7 +378,20 @@
);

ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) =>
shell.trashItem(path.join(workspaces, title))
shell.trashItem(getWorkspaceDir(title))
);

/** Moves one ended-early run (EEG file and behavior sibling) to the Trash. */
ipcMain.handle(
'fs:deleteIncompleteRecording',
async (_event, title: string, eegPath: string) => {
for (const file of incompleteRecordingFiles(
getWorkspaceDir(title),
eegPath
)) {
await shell.trashItem(file);
}
}
);

ipcMain.handle(
Expand Down Expand Up @@ -699,9 +738,9 @@
);

if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 741 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 743 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}

mainWindow.webContents.on('did-finish-load', () => {
Expand Down Expand Up @@ -744,7 +783,7 @@
lslInlets.destroyAll();
});

app.whenReady().then(async () => {

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 786 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
// Serve pyodide:// assets (whl files, manifest.json, etc.) directly from the
// filesystem via Electron's protocol API — no network socket required.
const pyodideRoot = is.dev
Expand Down Expand Up @@ -787,7 +826,7 @@
await session.defaultSession.clearCache();
}

createWindow();

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 829 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
});

app.on('activate', () => {
Expand Down
54 changes: 54 additions & 0 deletions src/main/recordings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,60 @@ export const isRawEEGFile = (file: string) => file.endsWith('raw.csv');
/** Behavior files Analyze and the workflow badges may offer; never incomplete ones. */
export const isBehaviorFile = (file: string) => file.endsWith('behavior.csv');

/** Ended-early raw EEG recordings, which Clean lists only so they can be deleted. */
export const isIncompleteRawEEGFile = (file: string) =>
file.endsWith(`-raw${INCOMPLETE}`);

/**
* Files `fs:deleteIncompleteRecording` trashes for one ended-early run: the EEG
* file plus, when present, its `Behavior/<stem>-behavior.incomplete.csv` and
* `EEG/<stem>-events.json` siblings. `eegPath` comes from the renderer, so after
* resolving symlinks it must be a regular file at exactly
* `<workspaceDir>/Data/<subject>/EEG/<stem>-raw.incomplete.csv` (and each sibling
* a regular file in its expected folder); otherwise throws.
*/
export const incompleteRecordingFiles = (
workspaceDir: string,
eegPath: string
) => {
const reject = (): never => {
throw new Error(
`Not an ended-early recording in this workspace: ${eegPath}`
);
};
const dataDir = fs.realpathSync(path.join(workspaceDir, 'Data'));
const eeg = fs.realpathSync(eegPath);
const [subject, folder, file, ...rest] = path
.relative(dataDir, eeg)
.split(path.sep);
if (
rest.length > 0 ||
!file ||
subject === '..' ||
folder !== 'EEG' ||
!isIncompleteRawEEGFile(file) ||
!fs.statSync(eeg).isFile()
) {
reject();
}
const stem = file.slice(0, -`-raw${INCOMPLETE}`.length);
const siblings = [
path.join(dataDir, subject, 'Behavior', `${stem}-behavior${INCOMPLETE}`),
path.join(dataDir, subject, 'EEG', `${stem}-events.json`),
].filter((sibling) => fs.existsSync(sibling));
const realSiblings = siblings.map((sibling) => {
const real = fs.realpathSync(sibling);
if (
path.dirname(real) !== path.dirname(sibling) ||
!fs.statSync(real).isFile()
) {
reject();
}
return real;
});
return [eeg, ...realSiblings];
};

/** True when any artifact of this session exists, complete or ended early. */
export const recordingExists = (
workspaceDir: string,
Expand Down
6 changes: 6 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
readWorkspaceRawEEGData: (title: string) =>
ipcRenderer.invoke('fs:readWorkspaceRawEEGData', title),

readWorkspaceIncompleteEEGData: (title: string) =>
ipcRenderer.invoke('fs:readWorkspaceIncompleteEEGData', title),

readWorkspaceCleanedEEGData: (title: string) =>
ipcRenderer.invoke('fs:readWorkspaceCleanedEEGData', title),

Expand Down Expand Up @@ -118,6 +121,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
deleteWorkspaceDir: (title: string): Promise<void> =>
ipcRenderer.invoke('fs:deleteWorkspaceDir', title),

deleteIncompleteRecording: (title: string, eegPath: string): Promise<void> =>
ipcRenderer.invoke('fs:deleteIncompleteRecording', title, eegPath),

importExperimentFile: (
title: string,
sourcePath: string
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/actions/pyodideActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export const PyodideActions = {
SetPyodideWorker: createAction<Worker, 'SET_PYODIDE_WORKER'>(
'SET_PYODIDE_WORKER'
),
LoadEpochs: createAction<string[], 'LOAD_EPOCHS'>('LOAD_EPOCHS'),
/** One raw recording path: a flagged sensor belongs to one recording. */
LoadEpochs: createAction<string, 'LOAD_EPOCHS'>('LOAD_EPOCHS'),
LoadCleanedEpochs: createAction<string[], 'LOAD_CLEANED_EPOCHS'>(
'LOAD_CLEANED_EPOCHS'
),
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/components/AppShell/WorkspaceAreaGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ export default function WorkspaceAreaGate({ area, children }: Props) {
const navigate = useNavigate();

const modality = experiment.isEEGEnabled ? 'eeg' : 'behavior';
const { counts } = useWorkspaceProgress(experiment.title, modality, pathname);
const { counts } = useWorkspaceProgress(
experiment.title,
modality,
pathname,
0
);

if (counts === null) return null;

Expand Down
9 changes: 6 additions & 3 deletions src/renderer/components/AppShell/useWorkspaceProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,15 @@ export function summarize(

/**
* Reads the workspace folder and derives AppShell's `badges` / `nextArea`.
* Refreshes on navigation; an unreadable or missing folder counts as zero.
* Refreshes on navigation and whenever `refresh` changes (e.g. a cleaned save
* lands without leaving the screen); an unreadable or missing folder counts as
* zero.
*/
export function useWorkspaceProgress(
title: string | undefined,
modality: Modality,
pathname: string
pathname: string,
refresh: number
): WorkspaceProgress {
const [progress, setProgress] = useState<WorkspaceProgress>(EMPTY);

Expand Down Expand Up @@ -83,7 +86,7 @@ export function useWorkspaceProgress(
return () => {
cancelled = true;
};
}, [title, modality, pathname]);
}, [title, modality, pathname, refresh]);

return progress;
}
5 changes: 1 addition & 4 deletions src/renderer/components/Clean/CleanDatasetSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,6 @@ export default function CleanDatasetSelect({
/>
<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>
);
Expand Down Expand Up @@ -184,7 +181,7 @@ export default function CleanDatasetSelect({
<ConfirmDialog
open={deletingRecording !== null}
title="Delete this ended-early recording?"
body={`${deletingRecording?.name ?? ''} will be deleted from your workspace. This can't be undone.`}
body={`${deletingRecording?.name ?? ''} will be moved to your computer's Trash.`}
confirmLabel="Delete recording"
destructive
onConfirm={onDeleteConfirm}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/components/Clean/CleanParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export function CleanLayout({
* 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.
*
* ponytail: canvases go soft below 1× zoom (the Live ERP sits near 0.6); make
* `EpochReviewer`/`LiveErpPane` draw at their box size (resize-aware canvases)
* and drop this when that matters.
*/
export function FitPane({
logicalWidth,
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/Clean/CleanPrimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const PRIMER_COPY: Record<
> = {
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.',
body: 'Every column in the Trials 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: {
Expand Down
Loading
Loading