From 282fd1f6788deb447fd9e80700f2e6cc321237f9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 6 Jul 2026 21:07:32 +0000
Subject: [PATCH 1/5] Delete dead frontend assets and unused global typings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the pre-React window declaration (src/global.d.ts, unreferenced) and
the empty/unused SVGs (react, uparrow, downarrow, trash, plus) — all verified
unreferenced across src.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01QMjKvi9vHT3LfikGfW84Kg
---
packages/frontend/src/assets/downarrow.svg | 14 --------------
packages/frontend/src/assets/plus.svg | 14 --------------
packages/frontend/src/assets/react.svg | 1 -
packages/frontend/src/assets/trash.svg | 12 ------------
packages/frontend/src/assets/uparrow.svg | 14 --------------
packages/frontend/src/global.d.ts | 12 ------------
6 files changed, 67 deletions(-)
delete mode 100644 packages/frontend/src/assets/downarrow.svg
delete mode 100644 packages/frontend/src/assets/plus.svg
delete mode 100644 packages/frontend/src/assets/react.svg
delete mode 100644 packages/frontend/src/assets/trash.svg
delete mode 100644 packages/frontend/src/assets/uparrow.svg
delete mode 100644 packages/frontend/src/global.d.ts
diff --git a/packages/frontend/src/assets/downarrow.svg b/packages/frontend/src/assets/downarrow.svg
deleted file mode 100644
index b2e3bb2..0000000
--- a/packages/frontend/src/assets/downarrow.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/frontend/src/assets/plus.svg b/packages/frontend/src/assets/plus.svg
deleted file mode 100644
index d594686..0000000
--- a/packages/frontend/src/assets/plus.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/packages/frontend/src/assets/react.svg b/packages/frontend/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/packages/frontend/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/frontend/src/assets/trash.svg b/packages/frontend/src/assets/trash.svg
deleted file mode 100644
index ef89567..0000000
--- a/packages/frontend/src/assets/trash.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/packages/frontend/src/assets/uparrow.svg b/packages/frontend/src/assets/uparrow.svg
deleted file mode 100644
index 7f7517c..0000000
--- a/packages/frontend/src/assets/uparrow.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/frontend/src/global.d.ts b/packages/frontend/src/global.d.ts
deleted file mode 100644
index 0a958ad..0000000
--- a/packages/frontend/src/global.d.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-declare global {
- interface Window {
- JsPsychMetadata: new () => JsPsychMetadataInstance;
- }
- }
-
- interface JsPsychMetadataInstance {
- generate: (data: any, metadataOptions?: object, csv?: boolean) => void;
- getMetadata: () => object;
- }
-
- export {};
\ No newline at end of file
From ce50cf07951d471d15b744a7b5215f93fd7e3851 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 6 Jul 2026 21:08:18 +0000
Subject: [PATCH 2/5] Zip download: sequential single-worker streaming (issue
#95)
Compress dataset entries strictly one at a time: for each file create a single
AsyncZipDeflate, feed it in bounded ~1 MiB Blob slices, and await that entry's
final chunk before starting the next. fflate spawns one worker per live
AsyncZipDeflate and buffers later entries until earlier ones finish, so the old
"create every deflater up front" loop held N workers plus most of the compressed
dataset in the heap; this keeps ~one worker and one file's working set resident.
Public API is unchanged; the header comment now matches reality.
Tests: fake-fflate ordering test asserts entry N+1 isn't created/pushed before
entry N emits its final chunk; downloadDatasetZip (previously uncovered) gains
picker-abort -> false, streaming-error -> sink.abort()+propagate, blob fallback,
and streaming-success cases.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01QMjKvi9vHT3LfikGfW84Kg
---
packages/frontend/src/staging/datasetZip.ts | 70 +++++++++++++++----
packages/frontend/tests/datasetZip.test.ts | 64 ++++++++++++++++-
.../tests/datasetZipStreaming.test.ts | 68 ++++++++++++++++++
3 files changed, 186 insertions(+), 16 deletions(-)
create mode 100644 packages/frontend/tests/datasetZipStreaming.test.ts
diff --git a/packages/frontend/src/staging/datasetZip.ts b/packages/frontend/src/staging/datasetZip.ts
index 5ea0a35..bab6ea7 100644
--- a/packages/frontend/src/staging/datasetZip.ts
+++ b/packages/frontend/src/staging/datasetZip.ts
@@ -1,17 +1,24 @@
// Builds and streams the downloadable Psych-DS dataset zip from the staged file store.
//
-// Uses fflate's AsyncZipDeflate (DEFLATE compressed, worker-backed when available) to emit
-// output chunks as each file finishes compressing — so neither the full input set nor the
-// complete output zip ever lives in the JS heap at once. On Chromium (showSaveFilePicker) each
-// chunk is written to the user-chosen file as it arrives, bounding peak heap to roughly one
-// file's working set. On other browsers (or if the picker fails) chunks are collected into a
-// Blob and downloaded via an object URL.
+// Files are compressed strictly one at a time. For each entry we create a single fflate
+// AsyncZipDeflate, feed the file's bytes in bounded ~1 MiB slices (read lazily from the
+// disk-backed Blob), and wait for that entry's final compressed chunk before starting the next
+// one. fflate spawns one worker per live AsyncZipDeflate and buffers later entries until earlier
+// ones finish, so processing sequentially keeps exactly one worker (and roughly one file's
+// working set) resident at a time rather than N workers plus most of the compressed dataset in
+// the heap. Output chunks are emitted as they are produced: on Chromium (showSaveFilePicker) each
+// is written to the user-chosen file as it arrives, bounding peak heap to about one file's
+// working set; on other browsers (or if the picker fails) chunks are collected into a Blob and
+// downloaded via an object URL.
import { Zip, AsyncZipDeflate } from 'fflate';
import { DATASET_DESCRIPTION_FILENAME } from '../datasetLayout';
import { blobDownload } from '../download';
import type { DatasetFileSource } from './stagedFileStore';
+/** Slice size for feeding a file into the deflater — bounds peak heap to ~one slice per file. */
+const PUSH_CHUNK_BYTES = 1 << 20; // 1 MiB
+
const readmeContents = (projectName: string): string =>
`# ${projectName}\nHuman-readable description of the project and dataset.`;
@@ -36,35 +43,68 @@ interface ZipSink {
/**
* Runs the zip build and routes each output chunk to `onChunk`. Resolves once the zip is
- * complete (all chunks delivered). Each data file is read from the store one at a time.
+ * complete (all chunks delivered). Entries are compressed strictly one at a time: each file is
+ * read from the store lazily, pushed into its own deflater in ~1 MiB slices, and awaited to its
+ * final compressed chunk before the next entry's deflater is created.
*/
async function buildZip(opts: BuildDatasetZipOptions, onChunk: (dat: Uint8Array) => void): Promise {
return new Promise((resolve, reject) => {
+ let failed = false;
+ const fail = (err: unknown) => { if (!failed) { failed = true; reject(err); } };
+
const zip = new Zip((err, dat, final) => {
- if (err) { reject(err); return; }
+ if (err) { fail(err); return; }
onChunk(dat);
if (final) resolve();
});
- const addEntry = (filename: string, content: Uint8Array): void => {
+ // Add one entry, feed it in bounded slices, and resolve only once its final compressed chunk
+ // has been emitted — so no second AsyncZipDeflate (hence no second worker) is created until
+ // this one is done.
+ const addEntrySequential = (filename: string, content: Blob): Promise => {
const entry = new AsyncZipDeflate(filename, { level: 6 });
zip.add(entry);
- entry.push(content, true);
+ // zip.add installs the routing handler that streams this entry's bytes into the archive;
+ // wrap it so we also learn when this entry has emitted its final chunk.
+ const routeToZip = entry.ondata!;
+ return new Promise((resolveEntry, rejectEntry) => {
+ entry.ondata = (err, dat, final) => {
+ routeToZip(err, dat, final);
+ if (err) rejectEntry(err);
+ else if (final) resolveEntry();
+ };
+ void (async () => {
+ try {
+ const size = content.size;
+ if (size === 0) { entry.push(new Uint8Array(0), true); return; }
+ for (let off = 0; off < size; off += PUSH_CHUNK_BYTES) {
+ const end = Math.min(off + PUSH_CHUNK_BYTES, size);
+ const slice = new Uint8Array(await content.slice(off, end).arrayBuffer());
+ entry.push(slice, end >= size);
+ }
+ } catch (e) {
+ rejectEntry(e);
+ }
+ })();
+ });
};
(async () => {
try {
- addEntry(DATASET_DESCRIPTION_FILENAME, new TextEncoder().encode(opts.metadataJson));
+ await addEntrySequential(
+ DATASET_DESCRIPTION_FILENAME,
+ new Blob([new TextEncoder().encode(opts.metadataJson)]),
+ );
if (opts.dataFiles) {
for await (const [path, blob] of opts.dataFiles.entries()) {
- addEntry(path, new Uint8Array(await blob.arrayBuffer()));
+ await addEntrySequential(path, blob);
}
}
- addEntry('README.md', new TextEncoder().encode(readmeContents(opts.projectName)));
- addEntry('CHANGES.md', new TextEncoder().encode(CHANGES_CONTENTS));
+ await addEntrySequential('README.md', new Blob([readmeContents(opts.projectName)]));
+ await addEntrySequential('CHANGES.md', new Blob([CHANGES_CONTENTS]));
zip.end();
} catch (e) {
- reject(e);
+ fail(e);
}
})();
});
diff --git a/packages/frontend/tests/datasetZip.test.ts b/packages/frontend/tests/datasetZip.test.ts
index 4384780..73f005a 100644
--- a/packages/frontend/tests/datasetZip.test.ts
+++ b/packages/frontend/tests/datasetZip.test.ts
@@ -1,5 +1,5 @@
import { unzipSync } from 'fflate';
-import { buildDatasetZipBlob } from '../src/staging/datasetZip';
+import { buildDatasetZipBlob, downloadDatasetZip } from '../src/staging/datasetZip';
import { createStagedFileStore } from '../src/staging/stagedFileStore';
async function readZip(blob: Blob): Promise> {
@@ -58,3 +58,65 @@ describe('buildDatasetZipBlob', () => {
expect(Object.keys(files).filter((k) => k.startsWith('data/'))).toHaveLength(0);
});
});
+
+type PickerWindow = { showSaveFilePicker?: (...args: unknown[]) => Promise };
+const pickerWindow = window as unknown as PickerWindow;
+
+describe('downloadDatasetZip', () => {
+ const originalPicker = pickerWindow.showSaveFilePicker;
+ afterEach(() => {
+ if (originalPicker === undefined) delete pickerWindow.showSaveFilePicker;
+ else pickerWindow.showSaveFilePicker = originalPicker;
+ });
+
+ test('returns false when the user aborts the save dialog', async () => {
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockRejectedValue(Object.assign(new Error('cancelled'), { name: 'AbortError' }));
+
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(false);
+ });
+
+ test('streams to the picked file and returns true on success', async () => {
+ const written: Uint8Array[] = [];
+ const writable = {
+ write: jest.fn(async (d: Uint8Array) => { written.push(d); }),
+ close: jest.fn(async () => {}),
+ abort: jest.fn(async () => {}),
+ };
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockResolvedValue({ createWritable: jest.fn().mockResolvedValue(writable) });
+
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(true);
+ expect(writable.write).toHaveBeenCalled();
+ expect(writable.close).toHaveBeenCalled();
+ expect(writable.abort).not.toHaveBeenCalled();
+ });
+
+ test('propagates streaming errors and aborts the sink', async () => {
+ const writable = {
+ write: jest.fn().mockRejectedValue(new Error('disk full')),
+ close: jest.fn(async () => {}),
+ abort: jest.fn(async () => {}),
+ };
+ pickerWindow.showSaveFilePicker = jest
+ .fn()
+ .mockResolvedValue({ createWritable: jest.fn().mockResolvedValue(writable) });
+
+ await expect(
+ downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip'),
+ ).rejects.toThrow('disk full');
+ expect(writable.abort).toHaveBeenCalled();
+ expect(writable.close).not.toHaveBeenCalled();
+ });
+
+ test('falls back to a blob download when the file-system picker is unavailable', async () => {
+ delete pickerWindow.showSaveFilePicker;
+ const result = await downloadDatasetZip({ metadataJson: '{"name":"x"}', projectName: 'x' }, 'x.zip');
+ expect(result).toBe(true);
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ });
+});
diff --git a/packages/frontend/tests/datasetZipStreaming.test.ts b/packages/frontend/tests/datasetZipStreaming.test.ts
new file mode 100644
index 0000000..c688598
--- /dev/null
+++ b/packages/frontend/tests/datasetZipStreaming.test.ts
@@ -0,0 +1,68 @@
+// Verifies the memory-bounded contract of the zip builder: entries are compressed strictly one at
+// a time, so entry N+1's deflater isn't created (and its bytes aren't pushed) until entry N has
+// emitted its final chunk. fflate is faked so we can observe the exact ordering of create/push/final
+// events — with the real (worker-backed) build this ordering isn't directly observable.
+
+const mockEvents: string[] = [];
+
+jest.mock('fflate', () => {
+ class FakeAsyncZipDeflate {
+ ondata: ((err: unknown, dat: Uint8Array, final: boolean) => void) | null = null;
+ constructor(public filename: string) {
+ mockEvents.push(`create:${filename}`);
+ }
+ push(data: Uint8Array, final: boolean) {
+ mockEvents.push(`push:${this.filename}:${final}`);
+ // Emit asynchronously so that a missing `await` between entries would let their pushes
+ // interleave — the ordering assertions below would then fail.
+ Promise.resolve().then(() => {
+ this.ondata?.(null, data, final);
+ if (final) mockEvents.push(`final:${this.filename}`);
+ });
+ }
+ }
+ class FakeZip {
+ constructor(private cb: (err: unknown, dat: Uint8Array, final: boolean) => void) {}
+ add(entry: FakeAsyncZipDeflate) {
+ // Route an entry's chunks into the archive stream (never the whole-zip `final`).
+ entry.ondata = (err, dat) => this.cb(err, dat, false);
+ }
+ end() {
+ this.cb(null, new Uint8Array(0), true);
+ }
+ }
+ return { Zip: FakeZip, AsyncZipDeflate: FakeAsyncZipDeflate };
+});
+
+import { buildDatasetZipBlob } from '../src/staging/datasetZip';
+import { createStagedFileStore } from '../src/staging/stagedFileStore';
+
+beforeEach(() => {
+ mockEvents.length = 0;
+});
+
+test('compresses entries strictly sequentially (N+1 not started before N finishes)', async () => {
+ const store = createStagedFileStore({ forceMemory: true });
+ await store.write('data/a.csv', 'aaa');
+ await store.write('data/b.csv', 'bbb');
+
+ await buildDatasetZipBlob({ metadataJson: '{}', projectName: 'p', dataFiles: store });
+
+ const order = ['dataset_description.json', 'data/a.csv', 'data/b.csv', 'README.md', 'CHANGES.md'];
+
+ // Every entry was created and finished.
+ for (const name of order) {
+ expect(mockEvents).toContain(`create:${name}`);
+ expect(mockEvents).toContain(`final:${name}`);
+ }
+
+ // Each entry finishes before the next is created or pushed.
+ for (let i = 0; i < order.length - 1; i++) {
+ const finalI = mockEvents.indexOf(`final:${order[i]}`);
+ const createNext = mockEvents.indexOf(`create:${order[i + 1]}`);
+ const pushNext = mockEvents.indexOf(`push:${order[i + 1]}:true`);
+ expect(finalI).toBeGreaterThanOrEqual(0);
+ expect(finalI).toBeLessThan(createNext);
+ expect(finalI).toBeLessThan(pushNext);
+ }
+});
From 0256e1b1c4fd913f55b97e524d482623e6901162 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 6 Jul 2026 21:08:36 +0000
Subject: [PATCH 3/5] Fix wizard state-loss bugs (revisit/upload/navigation)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Load existing metadata exactly once per uploaded file: gate ProjectInfo's load
effect on a file-identity token stored in the AppShell-level session, so
revisiting Project Info no longer re-runs loadMetadata and clobbers session
edits (resurrecting deleted authors / reverting variables). Propagate load
success/failure into the session and gate Data pre-completion and the "variables
loaded from existing metadata" banner on it, so a failed parse no longer
pre-completes Data.
Make metadata and staged data stay consistent across batches: "Upload
additional files" is now truly additive (no store.clear(), used-filename sets
persisted in the session and seeded for the new batch, files appended);
"Replace all data" / "Change folder" confirm first, then fully reset — clear the
store AND reset the metadata variables via a resetMetadata callback owned by
AppShell (drops generated variables, reloads the existing metadata file if
present, re-applies project-info fields). Re-configure join keys reprocesses the
whole dataset as a replace so it can't double-stage a batch.
Block sidebar navigation and Start over while a run is in flight (DataUpload
reports busy by phase; Sidebar gains a `locked` prop), with a polite processing
summary. Restore the 'ready' phase from session.files when files were picked but
not processed, and move sourceName into the session. Set webkitdirectory
declaratively on every folder input so it survives phase-branch remounts. Reset
the folder input value after selection; move the .psychds-ignore write inside the
per-file try so an OPFS failure can't hang the run. Warn before unload while
there is unsaved work, cleared after a download. aria-live on the pick error and
processing summary.
Tests: real-page AppShell integration (load-once + failed-parse gating), batch
consistency (additive keeps both batches + persists name sets; replace confirms,
clears the store, fires reset), navigation-lock busy reporting, session
round-trip, zip basename handling, and Sidebar locked. Existing AppShell/
DataUpload tests updated for the load-status gating and zip basename change.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01QMjKvi9vHT3LfikGfW84Kg
---
packages/frontend/src/components/AppShell.tsx | 75 ++++-
packages/frontend/src/components/Sidebar.tsx | 7 +-
.../frontend/src/pages/DataUpload.module.css | 69 +++++
packages/frontend/src/pages/DataUpload.tsx | 275 ++++++++++++++----
packages/frontend/src/pages/ProjectInfo.tsx | 75 ++++-
packages/frontend/tests/AppShell.test.tsx | 40 ++-
.../tests/AppShellIntegration.test.tsx | 71 +++++
packages/frontend/tests/DataUpload.test.tsx | 167 ++++++++++-
packages/frontend/tests/Sidebar.test.tsx | 17 ++
packages/frontend/tests/setup.ts | 16 +
10 files changed, 705 insertions(+), 107 deletions(-)
create mode 100644 packages/frontend/tests/AppShellIntegration.test.tsx
diff --git a/packages/frontend/src/components/AppShell.tsx b/packages/frontend/src/components/AppShell.tsx
index 53859ee..60664f6 100644
--- a/packages/frontend/src/components/AppShell.tsx
+++ b/packages/frontend/src/components/AppShell.tsx
@@ -1,8 +1,8 @@
-import { useState } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import JsPsychMetadata from '@jspsych/metadata';
import Sidebar from './Sidebar';
import PreviewDrawer from './PreviewDrawer';
-import ProjectInfo, { ProjectInfoSession, emptyProjectInfoSession } from '../pages/ProjectInfo';
+import ProjectInfo, { ProjectInfoSession, emptyProjectInfoSession, applyProjectInfoFields } from '../pages/ProjectInfo';
import DataUpload, { DataSession, emptyDataSession } from '../pages/DataUpload';
import Variables from '../pages/Variables';
import Authors from '../pages/Authors';
@@ -26,20 +26,64 @@ interface AppShellProps {
}
const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFile, onStartOver }) => {
- const isExistingProject = !!existingMetadataFile;
-
const [currentStep, setCurrentStep] = useState('projectInfo');
- // Pre-complete the Data step for existing projects — variables are already loaded from the JSON
- const [completedSteps, setCompletedSteps] = useState>(
- () => isExistingProject ? new Set(['data']) : new Set()
- );
+ const [completedSteps, setCompletedSteps] = useState>(() => new Set());
const [dataProcessed, setDataProcessed] = useState(false);
+ const [dataBusy, setDataBusy] = useState(false);
const [dataSession, setDataSession] = useState(emptyDataSession);
const [projectInfoSession, setProjectInfoSession] = useState(
() => emptyProjectInfoSession()
);
const [previewOpen, setPreviewOpen] = useState(false);
+ // An existing project's Data step is only "done for free" once its metadata actually loaded —
+ // a failed parse must not pre-complete Data or claim variables were loaded from it.
+ const existingLoaded = projectInfoSession.loadStatus === 'loaded';
+
+ // Pre-complete the Data step when an existing project's metadata loads successfully — its
+ // variables come from the JSON, so no data upload is required to advance.
+ useEffect(() => {
+ if (existingLoaded) {
+ setCompletedSteps(prev => (prev.has('data') ? prev : new Set([...prev, 'data'])));
+ }
+ }, [existingLoaded]);
+
+ // Latest project-info fields, read when rebuilding metadata after a data replace.
+ const projectInfoSessionRef = useRef(projectInfoSession);
+ projectInfoSessionRef.current = projectInfoSession;
+
+ // Full data reset for the "replace all data" flow: drop every generated variable so the
+ // metadata no longer describes the discarded dataset, then (for an existing project) restore
+ // the uploaded metadata file's variables and re-apply the user's edited project-info fields.
+ const resetMetadata = useCallback(async () => {
+ for (const name of jsPsychMetadata.getVariableNames()) jsPsychMetadata.deleteVariable(name);
+ if (existingMetadataFile) {
+ try {
+ jsPsychMetadata.loadMetadata(await existingMetadataFile.text());
+ } catch {
+ /* leave the cleared state if the file no longer parses */
+ }
+ applyProjectInfoFields(jsPsychMetadata, projectInfoSessionRef.current);
+ }
+ }, [jsPsychMetadata, existingMetadataFile]);
+
+ // Warn before an accidental tab close/reload while there's unsaved work (files staged or metadata
+ // edited) that hasn't been downloaded yet — nothing is persisted server-side. Lifted once the
+ // user downloads their dataset.
+ const [downloaded, setDownloaded] = useState(false);
+ const hasUnsavedWork =
+ !downloaded &&
+ (dataSession.files.length > 0 ||
+ (dataSession.convertedStore?.paths().length ?? 0) > 0 ||
+ projectInfoSession.name.trim() !== '' ||
+ projectInfoSession.description.trim() !== '');
+ useEffect(() => {
+ if (!hasUnsavedWork) return;
+ const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; };
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
+ }, [hasUnsavedWork]);
+
// Discard this session's on-disk staging before tearing the shell down — Start Over throws the
// whole project away, so the converted CSVs/raw originals shouldn't linger in OPFS (otherwise
// they sit there until the next startup sweep). Fire-and-forget: clear() swallows its own errors
@@ -90,8 +134,10 @@ const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFi
{ setDataProcessed(true); completeStep('data'); }}
+ onResetMetadata={resetMetadata}
+ onBusyChange={setDataBusy}
session={dataSession}
onSessionChange={setDataSession}
/>
@@ -101,7 +147,13 @@ const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFi
case 'authors':
return completeStep('authors')} />;
case 'review':
- return ;
+ return (
+ setDownloaded(true)}
+ />
+ );
}
};
@@ -112,8 +164,9 @@ const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFi
currentStep={currentStep}
completedSteps={completedSteps}
canNavigateTo={canNavigateTo}
- onNavigate={(stepId) => { if (canNavigateTo(stepId)) setCurrentStep(stepId); }}
+ onNavigate={(stepId) => { if (!dataBusy && canNavigateTo(stepId)) setCurrentStep(stepId); }}
onStartOver={handleStartOver}
+ locked={dataBusy}
/>
{renderStep()}
diff --git a/packages/frontend/src/components/Sidebar.tsx b/packages/frontend/src/components/Sidebar.tsx
index 9abee38..d267228 100644
--- a/packages/frontend/src/components/Sidebar.tsx
+++ b/packages/frontend/src/components/Sidebar.tsx
@@ -15,6 +15,8 @@ interface SidebarProps {
canNavigateTo: (stepId: StepId) => boolean;
onNavigate: (stepId: StepId) => void;
onStartOver: () => void;
+ /** When true (e.g. data is processing) all navigation is disabled so a run can't be orphaned. */
+ locked?: boolean;
}
const Sidebar: React.FC = ({
@@ -24,6 +26,7 @@ const Sidebar: React.FC = ({
canNavigateTo,
onNavigate,
onStartOver,
+ locked = false,
}) => {
const [confirming, setConfirming] = useState(false);
const dialogRef = useRef(null);
@@ -42,7 +45,7 @@ const Sidebar: React.FC = ({
{steps.map((step) => {
const isActive = step.id === currentStep;
const isCompleted = completedSteps.has(step.id);
- const isLocked = !canNavigateTo(step.id) && !isActive;
+ const isLocked = locked || (!canNavigateTo(step.id) && !isActive);
const cls = [
styles.step,
@@ -79,7 +82,7 @@ const Sidebar: React.FC = ({
>
What is Psych-DS? ↗
-
- inputRef.current?.click()}>
+ requestPick('folder', 'replace')}>
Upload data folder (optional)
or
- zipInputRef.current?.click()}>
+ requestPick('zip', 'replace')}>
Upload .zip (optional)