Compare commits

3 Commits

55 changed files with 4079 additions and 2018 deletions

View File

@@ -0,0 +1,58 @@
import { db } from "~/server/db";
import { steps, experiments, actions } from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
async function debugExperimentStructure() {
console.log("Debugging Experiment Structure for Interactive Storyteller...");
// Find the experiment
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"),
with: {
steps: {
orderBy: [asc(steps.orderIndex)],
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
}
}
}
}
});
if (!experiment) {
console.error("Experiment not found!");
return;
}
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
console.log("---------------------------------------------------");
experiment.steps.forEach((step, index) => {
console.log(`Step ${index + 1}: ${step.name}`);
console.log(` ID: ${step.id}`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
if (step.actions && step.actions.length > 0) {
console.log(` Actions (${step.actions.length}):`);
step.actions.forEach((action, actionIndex) => {
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
if (action.type === 'wizard_wait_for_response') {
console.log(` Options:`, JSON.stringify((action.parameters as any)?.options, null, 2));
}
});
}
console.log("---------------------------------------------------");
});
}
debugExperimentStructure()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
import { db } from "../src/server/db";
import { experiments, steps } from "../src/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectAllSteps() {
const result = await db.query.experiments.findMany({
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
columns: {
id: true,
name: true,
type: true,
orderIndex: true,
conditions: true,
}
}
}
});
console.log(`Found ${result.length} experiments.`);
for (const exp of result) {
console.log(`Experiment: ${exp.name} (${exp.id})`);
for (const step of exp.steps) {
// Only print conditional steps or the first step
if (step.type === 'conditional' || step.orderIndex === 0) {
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
}
}
console.log('---');
}
}
inspectAllSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,47 @@
import { db } from "~/server/db";
import { actions, steps } from "~/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectAction() {
console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab...");
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab";
const action = await db.query.actions.findFirst({
where: eq(actions.id, actionId),
with: {
step: {
columns: {
id: true,
name: true,
type: true,
conditions: true
}
}
}
});
if (!action) {
console.error("Action not found!");
return;
}
console.log("Action Found:");
console.log(" Name:", action.name);
console.log(" Type:", action.type);
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
console.log("Parent Step:");
console.log(" ID:", action.step.id);
console.log(" Name:", action.step.name);
console.log(" Type:", action.step.type);
console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2));
}
inspectAction()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
import { db } from "~/server/db";
import { steps } from "~/server/db/schema";
import { eq, inArray } from "drizzle-orm";
async function inspectBranchSteps() {
console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)...");
const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5";
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
const branchSteps = await db.query.steps.findMany({
where: inArray(steps.id, [step4Id, step5Id])
});
branchSteps.forEach(step => {
console.log(`Step: ${step.name} (${step.id})`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
console.log("---------------------------------------------------");
});
}
inspectBranchSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

24
scripts/inspect-db.ts Normal file
View File

@@ -0,0 +1,24 @@
import { db } from "../src/server/db";
import { steps } from "../src/server/db/schema";
import { eq, like } from "drizzle-orm";
async function checkSteps() {
const allSteps = await db.select().from(steps).where(like(steps.name, "%Comprehension Check%"));
console.log("Found steps:", allSteps.length);
for (const step of allSteps) {
console.log("Step Name:", step.name);
console.log("Type:", step.type);
console.log("Conditions (typeof):", typeof step.conditions);
console.log("Conditions (value):", JSON.stringify(step.conditions, null, 2));
}
}
checkSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

61
scripts/inspect-step.ts Normal file
View File

@@ -0,0 +1,61 @@
import { db } from "~/server/db";
import { steps, experiments } from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
async function inspectExperimentSteps() {
// Find experiment by ID
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603")
});
if (!experiment) {
console.log("Experiment not found!");
return;
}
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`);
const experimentSteps = await db.query.steps.findMany({
where: eq(steps.experimentId, experiment.id),
orderBy: [asc(steps.orderIndex)],
with: {
actions: {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)]
}
}
});
console.log(`Found ${experimentSteps.length} steps.`);
for (const step of experimentSteps) {
console.log("--------------------------------------------------");
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
console.log(`Name: ${step.name}`);
console.log(`Type: ${step.type}`);
console.log(`NextStepId: ${step.nextStepId}`);
if (step.type === 'conditional') {
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
}
if (step.actions.length > 0) {
console.log("Actions:");
for (const action of step.actions) {
console.log(` - [${action.orderIndex}] ${action.name} (${action.type})`);
if (action.type === 'wizard_wait_for_response') {
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
}
}
}
}
}
inspectExperimentSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,33 @@
import { db } from "../src/server/db";
import { experiments } from "../src/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectVisualDesign() {
const exps = await db.select().from(experiments);
for (const exp of exps) {
console.log(`Experiment: ${exp.name}`);
if (exp.visualDesign) {
const vd = exp.visualDesign as any;
console.log("Visual Design Steps:");
if (vd.steps && Array.isArray(vd.steps)) {
vd.steps.forEach((s: any, i: number) => {
console.log(` [${i}] ${s.name} (${s.type})`);
console.log(` Trigger: ${JSON.stringify(s.trigger)}`);
});
} else {
console.log(" No steps in visualDesign or invalid format.");
}
} else {
console.log(" No visualDesign blob.");
}
}
}
inspectVisualDesign()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,69 @@
import { db } from "~/server/db";
import { actions, steps } from "~/server/db/schema";
import { eq, sql } from "drizzle-orm";
async function patchActionParams() {
console.log("Patching Action Parameters for Interactive Storyteller...");
// Target Step IDs
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice
// 1. Get the authoritative conditions from the Step
const step = await db.query.steps.findFirst({
where: eq(steps.id, step3CondId)
});
if (!step) {
console.error("Step 3 not found!");
return;
}
const conditions = step.conditions as any;
const richOptions = conditions?.options;
if (!richOptions || !Array.isArray(richOptions)) {
console.error("Step 3 conditions are missing valid options!");
return;
}
console.log("Found rich options in Step:", JSON.stringify(richOptions, null, 2));
// 2. Get the Action
const action = await db.query.actions.findFirst({
where: eq(actions.id, actionId)
});
if (!action) {
console.error("Action not found!");
return;
}
console.log("Current Action Parameters:", JSON.stringify(action.parameters, null, 2));
// 3. Patch the Action Parameters
// We replace the simple string options with the rich object options
const currentParams = action.parameters as any;
const newParams = {
...currentParams,
options: richOptions // Overwrite with rich options from step
};
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
await db.execute(sql`
UPDATE hs_action
SET parameters = ${JSON.stringify(newParams)}::jsonb
WHERE id = ${actionId}
`);
console.log("Action parameters successfully patched.");
}
patchActionParams()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,92 @@
import { db } from "~/server/db";
import { steps } from "~/server/db/schema";
import { eq, sql } from "drizzle-orm";
async function patchBranchSteps() {
console.log("Patching branch steps for Interactive Storyteller...");
// Target Step IDs (From debug output)
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct)
const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect)
const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion
// Update Step 3 (The Conditional Step)
console.log("Updating Step 3 (Conditional Step)...");
const step3Conditional = await db.query.steps.findFirst({
where: eq(steps.id, step3CondId)
});
if (step3Conditional) {
const currentConditions = (step3Conditional.conditions as any) || {};
const options = currentConditions.options || [];
// Patch options to point to real step IDs
const newOptions = options.map((opt: any) => {
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
if (opt.value === "Incorrect") return { ...opt, nextStepId: stepBranchBId };
return opt;
});
const newConditions = { ...currentConditions, options: newOptions };
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${step3CondId}
`);
console.log("Step 3 (Conditional) updated links.");
} else {
console.log("Step 3 (Conditional) not found.");
}
// Update Step 4 (Branch A)
console.log("Updating Step 4 (Branch A)...");
/*
Note: We already patched Step 4 in previous run but under wrong assumption?
Let's re-patch to be safe.
Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5
It should jump to Conclusion (cc3fbc7f...)
*/
const stepBranchA = await db.query.steps.findFirst({
where: eq(steps.id, stepBranchAId)
});
if (stepBranchA) {
const currentConditions = (stepBranchA.conditions as Record<string, unknown>) || {};
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchAId}
`);
console.log("Step 4 (Branch A) updated jump target.");
}
// Update Step 5 (Branch B)
console.log("Updating Step 5 (Branch B)...");
const stepBranchB = await db.query.steps.findFirst({
where: eq(steps.id, stepBranchBId)
});
if (stepBranchB) {
const currentConditions = (stepBranchB.conditions as Record<string, unknown>) || {};
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchBId}
`);
console.log("Step 5 (Branch B) updated jump target.");
}
}
patchBranchSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -35,6 +35,19 @@ async function loadNaoPluginDef() {
// Global variable to hold the loaded definition // Global variable to hold the loaded definition
let NAO_PLUGIN_DEF: any; let NAO_PLUGIN_DEF: any;
let CORE_PLUGIN_DEF: any;
let WOZ_PLUGIN_DEF: any;
function loadSystemPlugin(filename: string) {
const LOCAL_PATH = path.join(__dirname, `../src/plugins/definitions/${filename}`);
try {
const raw = fs.readFileSync(LOCAL_PATH, "utf-8");
return JSON.parse(raw);
} catch (err) {
console.error(`❌ Failed to load system plugin ${filename}:`, err);
process.exit(1);
}
}
async function main() { async function main() {
console.log("🌱 Starting realistic seed script..."); console.log("🌱 Starting realistic seed script...");
@@ -43,6 +56,8 @@ async function main() {
try { try {
NAO_PLUGIN_DEF = await loadNaoPluginDef(); NAO_PLUGIN_DEF = await loadNaoPluginDef();
CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json");
WOZ_PLUGIN_DEF = loadSystemPlugin("hristudio-woz.json");
// Ensure legacy 'actions' property maps to 'actionDefinitions' if needed, though schema supports both if we map it // Ensure legacy 'actions' property maps to 'actionDefinitions' if needed, though schema supports both if we map it
if (NAO_PLUGIN_DEF.actions && !NAO_PLUGIN_DEF.actionDefinitions) { if (NAO_PLUGIN_DEF.actions && !NAO_PLUGIN_DEF.actionDefinitions) {
@@ -61,6 +76,7 @@ async function main() {
await db.delete(schema.studyPlugins).where(sql`1=1`); await db.delete(schema.studyPlugins).where(sql`1=1`);
await db.delete(schema.studyMembers).where(sql`1=1`); await db.delete(schema.studyMembers).where(sql`1=1`);
await db.delete(schema.studies).where(sql`1=1`); await db.delete(schema.studies).where(sql`1=1`);
await db.delete(schema.studies).where(sql`1=1`);
await db.delete(schema.plugins).where(sql`1=1`); await db.delete(schema.plugins).where(sql`1=1`);
await db.delete(schema.pluginRepositories).where(sql`1=1`); await db.delete(schema.pluginRepositories).where(sql`1=1`);
await db.delete(schema.userSystemRoles).where(sql`1=1`); await db.delete(schema.userSystemRoles).where(sql`1=1`);
@@ -144,12 +160,51 @@ async function main() {
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" } { studyId: study!.id, userId: researcherUser!.id, role: "researcher" }
]); ]);
await db.insert(schema.studyPlugins).values({ // Insert System Plugins
studyId: study!.id, const [corePlugin] = await db.insert(schema.plugins).values({
pluginId: naoPlugin!.id, name: CORE_PLUGIN_DEF.name,
configuration: { robotIp: "10.0.0.42" }, version: CORE_PLUGIN_DEF.version,
installedBy: adminUser.id description: CORE_PLUGIN_DEF.description,
}); author: CORE_PLUGIN_DEF.author,
trustLevel: "official",
actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions,
robotId: null, // System Plugin
metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id },
status: "active"
}).returning();
const [wozPlugin] = await db.insert(schema.plugins).values({
name: WOZ_PLUGIN_DEF.name,
version: WOZ_PLUGIN_DEF.version,
description: WOZ_PLUGIN_DEF.description,
author: WOZ_PLUGIN_DEF.author,
trustLevel: "official",
actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions,
robotId: null, // System Plugin
metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id },
status: "active"
}).returning();
await db.insert(schema.studyPlugins).values([
{
studyId: study!.id,
pluginId: naoPlugin!.id,
configuration: { robotIp: "10.0.0.42" },
installedBy: adminUser.id
},
{
studyId: study!.id,
pluginId: corePlugin!.id,
configuration: {},
installedBy: adminUser.id
},
{
studyId: study!.id,
pluginId: wozPlugin!.id,
configuration: {},
installedBy: adminUser.id
}
]);
const [experiment] = await db.insert(schema.experiments).values({ const [experiment] = await db.insert(schema.experiments).values({
studyId: study!.id, studyId: study!.id,
@@ -256,16 +311,49 @@ async function main() {
} }
]); ]);
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
// --- Step 4a: Correct Response Branch ---
const [step4a] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch A: Correct Response",
description: "Response when participant says 'Red'",
type: "robot",
orderIndex: 3,
required: false,
durationEstimate: 20
}).returning();
// --- Step 4b: Incorrect Response Branch ---
const [step4b] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch B: Incorrect Response",
description: "Response when participant gives wrong answer",
type: "robot",
orderIndex: 4,
required: false,
durationEstimate: 20
}).returning();
// --- Step 3: Comprehension Check (Wizard Decision Point) --- // --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect) // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step3] = await db.insert(schema.steps).values({ const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "Comprehension Check", name: "Comprehension Check",
description: "Ask participant about rock color and wait for wizard input", description: "Ask participant about rock color and wait for wizard input",
type: "wizard", type: "conditional",
orderIndex: 2, orderIndex: 2,
required: true, required: true,
durationEstimate: 30 durationEstimate: 30,
conditions: {
variable: "last_wizard_response",
options: [
{ label: "Correct Response (Red)", value: "Correct", nextStepId: step4a!.id, variant: "default" },
{ label: "Incorrect Response", value: "Incorrect", nextStepId: step4b!.id, variant: "destructive" }
]
}
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
@@ -282,30 +370,30 @@ async function main() {
}, },
{ {
stepId: step3!.id, stepId: step3!.id,
name: "Wait for Wizard Input", name: "Wait for Choice",
type: "wizard_wait_for_response", type: "wizard_wait_for_response",
orderIndex: 1, orderIndex: 1,
// Define the options that will be presented to the Wizard
parameters: { parameters: {
prompt_text: "Did participant answer 'Red' correctly?", prompt_text: "Did participant answer 'Red' correctly?",
response_type: "verbal", options: ["Correct", "Incorrect"]
timeout: 60
}, },
sourceKind: "core", sourceKind: "core",
pluginId: "hristudio-woz", // Explicit link
category: "wizard" category: "wizard"
},
{
stepId: step3!.id,
name: "Branch Decision",
type: "branch",
orderIndex: 2,
parameters: {},
sourceKind: "core",
pluginId: "hristudio-core", // Explicit link
category: "control"
} }
]); ]);
// --- Step 4a: Correct Response Branch ---
const [step4a] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch A: Correct Response",
description: "Response when participant says 'Red'",
type: "robot",
orderIndex: 3,
required: false,
durationEstimate: 20
}).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step4a!.id, stepId: step4a!.id,
@@ -342,16 +430,7 @@ async function main() {
} }
]); ]);
// --- Step 4b: Incorrect Response Branch ---
const [step4b] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch B: Incorrect Response",
description: "Response when participant gives wrong answer",
type: "robot",
orderIndex: 4,
required: false,
durationEstimate: 20
}).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {

View File

@@ -0,0 +1,78 @@
// Mock of the logic in WizardInterface.tsx handleNextStep
const steps = [
{
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
name: "Step 3 (Conditional)",
order: 2
},
{
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
name: "Step 4 (Branch A)",
order: 3,
conditions: {
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
}
},
{
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
name: "Step 5 (Branch B)",
order: 4,
conditions: {
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
}
},
{
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
name: "Step 6 (Conclusion)",
order: 5
}
];
function simulateNextStep(currentStepIndex: number) {
const currentStep = steps[currentStepIndex];
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
// Logic from WizardInterface.tsx
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
if (currentStep?.conditions?.nextStepId) {
const nextId = String(currentStep.conditions.nextStepId);
const targetIndex = steps.findIndex(s => s.id === nextId);
console.log(`Target ID: ${nextId}`);
console.log(`Target Index Found: ${targetIndex}`);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
return targetIndex;
} else {
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
}
} else {
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
}
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
console.log(`Proceeding linearly to index ${nextIndex}`);
return nextIndex;
}
// Simulate Branch A (Index 1 in this array, but 3 in real experiment?)
// In real exp, Step 3 is index 2. Step 4 (Branch A) is index 3.
console.log("Real experiment indices:");
// 0: Hook, 1: Narrative, 2: Conditional, 3: Branch A, 4: Branch B, 5: Conclusion
const indexStep4 = 1; // logical index in my mock array
const indexStep5 = 2; // logical index
console.log("Testing Branch A Logic:");
const resultA = simulateNextStep(indexStep4);
if (resultA === 3) console.log("SUCCESS: Branch A jumped to Conclusion");
else console.log("FAILURE: Branch A fell through");
console.log("\nTesting Branch B Logic:");
const resultB = simulateNextStep(indexStep5);
if (resultB === 3) console.log("SUCCESS: Branch B jumped to Conclusion");
else console.log("FAILURE: Branch B fell through");

View File

@@ -0,0 +1,44 @@
import { db } from "../src/server/db";
import { experiments } from "../src/server/db/schema";
import { eq, asc } from "drizzle-orm";
import { convertDatabaseToSteps } from "../src/lib/experiment-designer/block-converter";
async function verifyConversion() {
const experiment = await db.query.experiments.findFirst({
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
with: {
actions: {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
}
}
}
}
});
if (!experiment) {
console.log("No experiment found");
return;
}
console.log("Raw DB Steps Count:", experiment.steps.length);
const converted = convertDatabaseToSteps(experiment.steps);
console.log("Converted Steps:");
converted.forEach((s, idx) => {
console.log(`[${idx}] ${s.name} (${s.type})`);
console.log(` Trigger:`, JSON.stringify(s.trigger));
if (s.type === 'conditional') {
console.log(` Conditions populated?`, Object.keys(s.trigger.conditions).length > 0);
}
});
}
verifyConversion()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
import { db } from "~/server/db";
import { experiments, steps, actions } from "~/server/db/schema";
import { eq, asc, desc } from "drizzle-orm";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
async function verifyTrpcLogic() {
console.log("Verifying TRPC Logic for Interactive Storyteller...");
// 1. Simulate the DB Query from experiments.ts
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"),
with: {
study: {
columns: {
id: true,
name: true,
},
},
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
robot: true,
steps: {
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
},
},
orderBy: [asc(steps.orderIndex)],
},
},
});
if (!experiment) {
console.error("Experiment not found!");
return;
}
// 2. Simulate the Transformation
console.log("Transforming DB steps to Designer steps...");
const transformedSteps = convertDatabaseToSteps(experiment.steps);
// 3. Inspect Step 4 (Branch A)
// Step index 3 (0-based) is Branch A
const branchAStep = transformedSteps[3];
console.log("Step 4 (Branch A):", branchAStep.name);
console.log(" Type:", branchAStep.type);
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
// Check conditions specifically
const conditions = branchAStep.trigger?.conditions as any;
if (conditions?.nextStepId) {
console.log("SUCCESS: nextStepId found in conditions:", conditions.nextStepId);
} else {
console.error("FAILURE: nextStepId MISSING in conditions!");
}
// Inspect Step 5 (Branch B) for completeness
const branchBStep = transformedSteps[4];
console.log("Step 5 (Branch B):", branchBStep.name);
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
}
verifyTrpcLogic()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,190 +1,15 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect } from "react";
import { import { BarChart3 } from "lucide-react";
BarChart3,
Search,
Filter,
PlayCircle,
Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView"; import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { formatDistanceToNow } from "date-fns";
// -- Sub-Components --
function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
{selectedTrialId ? (
isLoadingTrial ? (
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading trial data...</span>
</div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/>
</div>
)}
</div>
);
}
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const recentTrials = [...trials].sort((a, b) =>
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
).slice(0, 5);
return (
<div className="h-full p-8 grid place-items-center bg-muted/5">
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
{/* Left: Illustration / Prompt */}
<div className="flex flex-col justify-center space-y-4">
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
<BarChart3 className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardDescription className="text-base mt-2">
Select a session from the top right to review video recordings, event logs, and metrics.
</CardDescription>
</div>
<div className="flex gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlayCircle className="h-4 w-4" />
Feature-rich playback
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Synchronized timeline
</div>
</div>
</div>
{/* Right: Recent Sessions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[240px]">
<div className="px-4 pb-4 space-y-1">
{recentTrials.map(trial => (
<button
key={trial.id}
onClick={() => onSelect(trial.id)}
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
{trial.sessionNumber}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{trial.participant?.participantCode ?? "Unknown"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
// -- Main Page --
export default function StudyAnalyticsPage() { export default function StudyAnalyticsPage() {
const params = useParams(); const params = useParams();
@@ -192,11 +17,8 @@ export default function StudyAnalyticsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// State lifted up // Fetch list of trials
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null); const { data: trialsList, isLoading } = api.trials.list.useQuery(
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
{ studyId, limit: 100 }, { studyId, limit: 100 },
{ enabled: !!studyId } { enabled: !!studyId }
); );
@@ -217,50 +39,30 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
return ( return (
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6"> <div className="space-y-6">
<div className="flex-none"> <PageHeader
<PageHeader title="Analysis"
title="Analytics" description="View and analyze session data across all trials"
description="Analyze trial data and replay sessions" icon={BarChart3}
icon={BarChart3} />
actions={
<div className="flex items-center gap-2"> <div className="bg-transparent">
{/* Session Selector in Header */} <Suspense fallback={<div>Loading analytics...</div>}>
<div className="w-[300px]"> {isLoading ? (
<Select <div className="flex items-center justify-center h-64">
value={selectedTrialId ?? "overview"} <div className="flex flex-col items-center gap-2 animate-pulse">
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)} <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
> <span className="text-muted-foreground text-sm">Loading session data...</span>
<SelectTrigger className="w-full h-9 text-xs">
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
} ) : (
/> <StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
</div> ...t,
startedAt: t.startedAt ? new Date(t.startedAt) : null,
<div className="flex-1 min-h-0 bg-transparent"> completedAt: t.completedAt ? new Date(t.completedAt) : null,
<Suspense fallback={<div>Loading analytics...</div>}> createdAt: new Date(t.createdAt),
<AnalyticsContent }))} />
selectedTrialId={selectedTrialId} )}
setSelectedTrialId={setSelectedTrialId}
trialsList={trialsList ?? []}
isLoadingList={isLoadingList}
/>
</Suspense> </Suspense>
</div> </div>
</div> </div>

View File

@@ -222,7 +222,10 @@ export default async function ExperimentDesignerPage({
: "sequential"; : "sequential";
})(), })(),
order: s.orderIndex ?? idx, order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} }, trigger: {
type: idx === 0 ? "trial_start" : "previous_step",
conditions: (s.conditions as Record<string, unknown>) || {},
},
actions, actions,
expanded: true, expanded: true,
}; };

View File

@@ -13,6 +13,8 @@ import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react"; import { Edit } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps { interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>; params: Promise<{ id: string; participantId: string }>;
} }
@@ -61,6 +63,13 @@ export default async function ParticipantDetailPage({
<TabsContent value="overview"> <TabsContent value="overview">
<div className="grid gap-6 grid-cols-1"> <div className="grid gap-6 grid-cols-1">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/>
<EntityViewSection title="Participant Information" icon="Info"> <EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div> <div>

View File

@@ -95,25 +95,10 @@ function AnalysisPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <TrialAnalysisView
<PageHeader trial={trialData}
title="Trial Analysis" backHref={`/studies/${studyId}/trials/${trialId}`}
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`} />
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
/>
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
); );
} }

View File

@@ -171,10 +171,27 @@ function WizardPageContent() {
const renderView = () => { const renderView = () => {
const trialData = { const trialData = {
...trial, id: trial.id,
status: trial.status,
scheduledAt: trial.scheduledAt,
startedAt: trial.startedAt,
completedAt: trial.completedAt,
duration: trial.duration,
sessionNumber: trial.sessionNumber,
notes: trial.notes,
metadata: trial.metadata as Record<string, unknown> | null, metadata: trial.metadata as Record<string, unknown> | null,
experimentId: trial.experimentId,
participantId: trial.participantId,
wizardId: trial.wizardId,
experiment: {
id: trial.experiment.id,
name: trial.experiment.name,
description: trial.experiment.description,
studyId: trial.experiment.studyId,
},
participant: { participant: {
...trial.participant, id: trial.participant.id,
participantCode: trial.participant.participantCode,
demographics: trial.participant.demographics as Record< demographics: trial.participant.demographics as Record<
string, string,
unknown unknown
@@ -184,7 +201,7 @@ function WizardPageContent() {
switch (currentRole) { switch (currentRole) {
case "wizard": case "wizard":
return <WizardView trial={trialData} />; return <WizardView trial={trialData} userRole={currentRole} />;
case "observer": case "observer":
return <ObserverView trial={trialData} />; return <ObserverView trial={trialData} />;
case "participant": case "participant":
@@ -195,24 +212,8 @@ function WizardPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <div>
<PageHeader {renderView()}
title={getViewTitle(currentRole)}
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={getViewIcon(currentRole)}
actions={
currentRole !== "participant" ? (
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
) : null
}
/>
<div className="min-h-0 flex-1">{renderView()}</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,319 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useState } from "react";
import {
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = {
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
};
export const columns: ColumnDef<AnalyticsTrial>[] = [
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
},
{
accessorKey: "participant.participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${status === "completed"
? "bg-green-500/10 text-green-500 border-green-500/20"
: status === "in_progress"
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
: "bg-slate-500/10 text-slate-500 border-slate-500/20"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
</div>
)
},
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{row.getValue("eventCount")}</span>
</div>
)
},
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-muted-foreground" />
<span>{count}</span>
</div>
)
},
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[];
}
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -243,65 +243,57 @@ export const columns: ColumnDef<Experiment>[] = [
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => <ExperimentActions experiment={row.original} />,
const experiment = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Designer
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
>
<PlayCircle className="mr-2 h-4 w-4" />
Start Trial
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
}, },
]; ];
function ExperimentActions({ experiment }: { experiment: Experiment }) {
const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
utils.experiments.list.invalidate();
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Metadata
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Design
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ExperimentsTable() { export function ExperimentsTable() {
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();

View File

@@ -1,13 +1,15 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { ActionDefinition } from "~/lib/experiment-designer/types"; import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
/** /**
* ActionRegistry * ActionRegistry
* *
* Central singleton for loading and serving action definitions from: * Central singleton for loading and serving action definitions from:
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json) * - Core system action JSON manifests (hristudio-core, hristudio-woz)
* - Study-installed plugin action definitions (ROS2 / REST / internal transports) * - Study-installed plugin action definitions (ROS2 / REST / internal transports)
* *
* Responsibilities: * Responsibilities:
@@ -15,12 +17,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
* - Provenance retention (core vs plugin, plugin id/version, robot id) * - Provenance retention (core vs plugin, plugin id/version, robot id)
* - Parameter schema → UI parameter mapping (primitive only for now) * - Parameter schema → UI parameter mapping (primitive only for now)
* - Fallback action population if core load fails (ensures minimal functionality) * - Fallback action population if core load fails (ensures minimal functionality)
*
* Notes:
* - The registry is client-side only (designer runtime); server performs its own
* validation & compilation using persisted action instances (never trusts client).
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
*/ */
export class ActionRegistry { export class ActionRegistry {
private static instance: ActionRegistry; private static instance: ActionRegistry;
@@ -31,6 +27,8 @@ export class ActionRegistry {
private loadedStudyId: string | null = null; private loadedStudyId: string | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
static getInstance(): ActionRegistry { static getInstance(): ActionRegistry {
if (!ActionRegistry.instance) { if (!ActionRegistry.instance) {
ActionRegistry.instance = new ActionRegistry(); ActionRegistry.instance = new ActionRegistry();
@@ -49,234 +47,18 @@ export class ActionRegistry {
this.listeners.forEach((listener) => listener()); this.listeners.forEach((listener) => listener());
} }
/* ---------------- Core Actions ---------------- */ /* ---------------- Core / System Actions ---------------- */
async loadCoreActions(): Promise<void> { async loadCoreActions(): Promise<void> {
if (this.coreActionsLoaded) return; if (this.coreActionsLoaded) return;
interface CoreBlockParam { // Load System Plugins (Core & WoZ)
id: string; this.registerPluginDefinition(corePluginDef);
name: string; this.registerPluginDefinition(wozPluginDef);
type: string;
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number;
}
interface CoreBlock { console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
id: string;
name: string;
description?: string;
category: string;
icon?: string;
color?: string;
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
nestable?: boolean;
}
try { this.coreActionsLoaded = true;
const coreActionSets = [
"wizard-actions",
"control-flow",
"observation",
"events",
];
for (const actionSetId of coreActionSets) {
try {
const response = await fetch(
`/hristudio-core/plugins/${actionSetId}.json`,
);
// Non-blocking skip if not found
if (!response.ok) continue;
const rawActionSet = (await response.json()) as unknown;
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
// Register each block as an ActionDefinition
actionSet.blocks.forEach((block) => {
if (!block.id || !block.name) return;
const actionDef: ActionDefinition = {
id: block.id,
type: block.id,
name: block.name,
description: block.description ?? "",
category: this.mapBlockCategoryToActionCategory(block.category),
icon: block.icon ?? "Zap",
color: block.color ?? "#6b7280",
parameters: (block.parameters ?? []).map((param) => ({
id: param.id,
name: param.name,
type:
(param.type as "text" | "number" | "select" | "boolean") ||
"text",
placeholder: param.placeholder,
options: param.options,
min: param.min,
max: param.max,
value: param.value,
required: param.required !== false,
description: param.description,
step: param.step,
})),
source: {
kind: "core",
baseActionId: block.id,
},
execution: {
transport: "internal",
timeoutMs: block.timeoutMs,
retryable: block.retryable,
},
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
nestable: block.nestable,
};
this.actions.set(actionDef.id, actionDef);
});
} catch (error) {
// Non-fatal: we will fallback later
console.warn(`Failed to load core action set ${actionSetId}:`, error);
}
}
this.coreActionsLoaded = true;
this.notifyListeners();
} catch (error) {
console.error("Failed to load core actions:", error);
this.loadFallbackActions();
}
}
private mapBlockCategoryToActionCategory(
category: string,
): ActionDefinition["category"] {
switch (category) {
case "wizard":
return "wizard";
case "event":
return "wizard"; // Events are wizard-initiated triggers
case "robot":
return "robot";
case "control":
return "control";
case "sensor":
case "observation":
return "observation";
default:
return "wizard";
}
}
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_say",
type: "wizard_say",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#a855f7",
parameters: [
{
id: "message",
name: "Message",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
{
id: "tone",
name: "Tone",
type: "select",
options: ["neutral", "friendly", "encouraging"],
value: "neutral",
},
],
source: { kind: "core", baseActionId: "wizard_say" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {},
nestable: false,
},
{
id: "wait",
type: "wait",
name: "Wait",
description: "Wait for specified time",
category: "control",
icon: "Clock",
color: "#f59e0b",
parameters: [
{
id: "duration",
name: "Duration (seconds)",
type: "number",
min: 0.1,
max: 300,
value: 2,
required: true,
},
],
source: { kind: "core", baseActionId: "wait" },
execution: { transport: "internal", timeoutMs: 60000 },
parameterSchemaRaw: {
type: "object",
properties: {
duration: {
type: "number",
minimum: 0.1,
maximum: 300,
default: 2,
},
},
required: ["duration"],
},
},
{
id: "observe",
type: "observe",
name: "Observe",
description: "Record participant behavior",
category: "observation",
icon: "Eye",
color: "#8b5cf6",
parameters: [
{
id: "behavior",
name: "Behavior to observe",
type: "select",
options: ["facial_expression", "body_language", "verbal_response"],
required: true,
},
],
source: { kind: "core", baseActionId: "observe" },
execution: { transport: "internal", timeoutMs: 120000 },
parameterSchemaRaw: {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["facial_expression", "body_language", "verbal_response"],
},
},
required: ["behavior"],
},
},
];
fallbackActions.forEach((action) => this.actions.set(action.id, action));
this.notifyListeners(); this.notifyListeners();
} }
@@ -295,108 +77,133 @@ export class ActionRegistry {
let totalActionsLoaded = 0; let totalActionsLoaded = 0;
(studyPlugins ?? []).forEach((plugin) => { (studyPlugins ?? []).forEach((plugin) => {
const actionDefs = Array.isArray(plugin.actionDefinitions) this.registerPluginDefinition(plugin);
? plugin.actionDefinitions totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
: undefined;
if (!actionDefs) return;
actionDefs.forEach((action: any) => {
const rawCategory =
typeof action.category === "string"
? action.category.toLowerCase().trim()
: "";
const categoryMap: Record<string, ActionDefinition["category"]> = {
wizard: "wizard",
robot: "robot",
control: "control",
observation: "observation",
};
const category = categoryMap[rawCategory] ?? "robot";
const execution = action.ros2
? {
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: {
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic)
// Ideally, plugin.metadata.robotId should populate this.
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id;
const actionDef: ActionDefinition = {
id: `${semanticRobotId}.${action.id}`,
type: `${semanticRobotId}.${action.id}`,
name: action.name,
description: action.description ?? "",
category,
icon: action.icon ?? "Bot",
color: "#10b981",
parameters: this.convertParameterSchemaToParameters(
action.parameterSchema,
),
source: {
kind: "plugin",
pluginId: semanticRobotId, // Use semantic ID here too
robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined,
baseActionId: action.id,
},
execution,
parameterSchemaRaw: action.parameterSchema ?? undefined,
};
this.actions.set(actionDef.id, actionDef);
// Register aliases if provided by plugin metadata
const aliases = Array.isArray(action.aliases)
? action.aliases
: undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id);
}
}
}
totalActionsLoaded++;
});
}); });
console.log( console.log(
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`, `ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
); );
// console.log("Current action registry state:", { totalActions: this.actions.size });
this.pluginActionsLoaded = true; this.pluginActionsLoaded = true;
this.loadedStudyId = studyId; this.loadedStudyId = studyId;
this.notifyListeners(); this.notifyListeners();
} }
/* ---------------- Shared Registration Logic ---------------- */
private registerPluginDefinition(plugin: any) {
const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions
: undefined;
if (!actionDefs) return;
actionDefs.forEach((action: any) => {
const rawCategory =
typeof action.category === "string"
? action.category.toLowerCase().trim()
: "";
const categoryMap: Record<string, ActionDefinition["category"]> = {
wizard: "wizard",
robot: "robot",
control: "control",
observation: "observation",
};
// Default category based on plugin type or explicit category
let category = categoryMap[rawCategory];
if (!category) {
if (plugin.id === 'hristudio-woz') category = 'wizard';
else if (plugin.id === 'hristudio-core') category = 'control';
else category = 'robot';
}
const execution = action.ros2
? {
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: {
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
const semanticRobotId =
plugin.metadata?.robotId ||
plugin.metadata?.id ||
plugin.robotId ||
plugin.id;
// For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data
// For robot plugins, we namespace them (nao6-ros2.say_text)
const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId);
const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`;
const actionType = actionId; // Type is usually same as ID
const actionDef: ActionDefinition = {
id: actionId,
type: actionType,
name: action.name,
description: action.description ?? "",
category,
icon: action.icon ?? "Bot",
color: action.color || "#10b981",
parameters: this.convertParameterSchemaToParameters(
action.parameterSchema,
),
source: {
kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
pluginId: semanticRobotId,
robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined,
baseActionId: action.id,
},
execution,
parameterSchemaRaw: action.parameterSchema ?? undefined,
nestable: action.nestable
};
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
if (!this.actions.has(actionId)) {
this.actions.set(actionId, actionDef);
}
// Register aliases
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id);
}
}
}
});
}
private convertParameterSchemaToParameters( private convertParameterSchemaToParameters(
parameterSchema: unknown, parameterSchema: unknown,
): ActionDefinition["parameters"] { ): ActionDefinition["parameters"] {
@@ -417,7 +224,7 @@ export class ActionRegistry {
if (!schema?.properties) return []; if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => { return Object.entries(schema.properties).map(([key, paramDef]) => {
let type: "text" | "number" | "select" | "boolean" = "text"; let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
if (paramDef.type === "number") { if (paramDef.type === "number") {
type = "number"; type = "number";
@@ -425,6 +232,10 @@ export class ActionRegistry {
type = "boolean"; type = "boolean";
} else if (paramDef.enum && Array.isArray(paramDef.enum)) { } else if (paramDef.enum && Array.isArray(paramDef.enum)) {
type = "select"; type = "select";
} else if (paramDef.type === "array") {
type = "array";
} else if (paramDef.type === "object") {
type = "json";
} }
return { return {
@@ -444,29 +255,17 @@ export class ActionRegistry {
private resetPluginActions(): void { private resetPluginActions(): void {
this.pluginActionsLoaded = false; this.pluginActionsLoaded = false;
this.loadedStudyId = null; this.loadedStudyId = null;
// Remove existing plugin actions (retain known core ids + fallback ids)
const pluginActionIds = Array.from(this.actions.keys()).filter( // Robust Reset: Remove valid plugin actions, BUT protect system plugins.
(id) => const idsToDelete: string[] = [];
!id.startsWith("wizard_") && this.actions.forEach((action, id) => {
!id.startsWith("when_") && if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
!id.startsWith("wait") && idsToDelete.push(id);
!id.startsWith("observe") && }
!id.startsWith("repeat") && });
!id.startsWith("if_") &&
!id.startsWith("parallel") && idsToDelete.forEach((id) => this.actions.delete(id));
!id.startsWith("sequence") && this.notifyListeners();
!id.startsWith("random_") &&
!id.startsWith("try_") &&
!id.startsWith("break") &&
!id.startsWith("measure_") &&
!id.startsWith("count_") &&
!id.startsWith("record_") &&
!id.startsWith("capture_") &&
!id.startsWith("log_") &&
!id.startsWith("survey_") &&
!id.startsWith("physiological_"),
);
pluginActionIds.forEach((id) => this.actions.delete(id));
} }
/* ---------------- Query Helpers ---------------- */ /* ---------------- Query Helpers ---------------- */

View File

@@ -116,10 +116,21 @@ interface RawExperiment {
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest // 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
// plugin provenance data (which might be missing from stale visualDesign snapshots). // plugin provenance data (which might be missing from stale visualDesign snapshots).
// 1. Prefer database steps (Source of Truth) if valid.
if (Array.isArray(exp.steps) && exp.steps.length > 0) { if (Array.isArray(exp.steps) && exp.steps.length > 0) {
try { try {
// console.log('[DesignerRoot] Hydrating design from Database Steps (Source of Truth)'); // Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const dbSteps = convertDatabaseToSteps(exp.steps); const firstStep = exp.steps[0] as any;
let dbSteps: ExperimentStep[];
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
// Already converted by server
dbSteps = exp.steps as ExperimentStep[];
} else {
// Raw DB steps, need conversion
dbSteps = convertDatabaseToSteps(exp.steps);
}
return { return {
id: exp.id, id: exp.id,
name: exp.name, name: exp.name,
@@ -129,7 +140,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved: new Date(), lastSaved: new Date(),
}; };
} catch (err) { } catch (err) {
console.warn('[DesignerRoot] Failed to convert DB steps, falling back to visualDesign:', err); console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
} }
} }
@@ -616,6 +627,9 @@ export function DesignerRoot({
setLastSavedAt(new Date()); setLastSavedAt(new Date());
toast.success("Experiment saved"); toast.success("Experiment saved");
// Auto-validate after save to clear "Modified" (drift) status
void validateDesign();
console.log('[DesignerRoot] 💾 SAVE complete'); console.log('[DesignerRoot] 💾 SAVE complete');
onPersist?.({ onPersist?.({

View File

@@ -23,6 +23,7 @@ import {
type ExperimentDesign, type ExperimentDesign,
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry } from "./ActionRegistry";
import { Button } from "~/components/ui/button";
import { import {
Settings, Settings,
Zap, Zap,
@@ -39,6 +40,9 @@ import {
Mic, Mic,
Activity, Activity,
Play, Play,
Plus,
GitBranch,
Trash2,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -275,35 +279,166 @@ export function PropertiesPanelBase({
</div> </div>
</div> </div>
{/* Parameters */} {/* Branching Configuration (Special Case) */}
{def?.parameters.length ? ( {selectedAction.type === "branch" ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase"> <div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
Parameters <span>Branch Options</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => {
const currentOptions = (containingStep.trigger.conditions as any)?.options || [];
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: [
...currentOptions,
{ label: "New Option", nextStepIndex: containingStep.order + 1, variant: "default" }
]
}
}
});
// Auto-upgrade step type if needed
if (containingStep.type !== "conditional") {
onStepUpdate(containingStep.id, { type: "conditional" });
}
}}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{def.parameters.map((param) => ( {((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => (
<ParameterEditor <div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
key={param.id} <div className="flex gap-2">
param={param} <div className="flex-1">
value={selectedAction.parameters[param.id]} <Label className="text-[10px]">Label</Label>
onUpdate={(val) => { <Input
onActionUpdate(containingStep.id, selectedAction.id, { value={opt.label}
parameters: { onChange={(e) => {
...selectedAction.parameters, const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
[param.id]: val, newOpts[idx] = { ...newOpts[idx], label: e.target.value };
}, onStepUpdate(containingStep.id, {
}); trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
}} });
onCommit={() => { }} }}
/> className="h-7 text-xs"
/>
</div>
<div className="w-[80px]">
<Label className="text-[10px]">Target Step</Label>
<Select
value={opt.nextStepId ?? design.steps[opt.nextStepIndex]?.id ?? ""}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
// Find index for legacy support / display logic if needed
const stepIdx = design.steps.findIndex(s => s.id === val);
newOpts[idx] = {
...newOpts[idx],
nextStepId: val,
nextStepIndex: stepIdx !== -1 ? stepIdx : undefined
};
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="Select step..." />
</SelectTrigger>
<SelectContent>
{design.steps.map((s) => (
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
{s.order + 1}. {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Select
value={opt.variant || "default"}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
newOpts[idx] = { ...newOpts[idx], variant: val };
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Next)</SelectItem>
<SelectItem value="destructive">Destructive (Red)</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
newOpts.splice(idx, 1);
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))} ))}
{(!((containingStep.trigger.conditions as any)?.options?.length)) && (
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
No options defined.<br />Click + to add a branch.
</div>
)}
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-muted-foreground text-xs"> /* Standard Parameters */
No parameters for this action. def?.parameters.length ? (
</div> <div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3">
{def.parameters.map((param) => (
<ParameterEditor
key={param.id}
param={param}
value={selectedAction.parameters[param.id]}
onUpdate={(val) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: val,
},
});
}}
onCommit={() => { }}
/>
))}
</div>
</div>
) : (
<div className="text-muted-foreground text-xs">
No parameters for this action.
</div>
)
)} )}
</div> </div>
); );

View File

@@ -29,6 +29,7 @@ import {
Trash2, Trash2,
GitBranch, GitBranch,
Edit3, Edit3,
CornerDownRight,
} from "lucide-react"; } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
@@ -96,6 +97,7 @@ interface StepRowProps {
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: 'up' | 'down') => void; onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void; onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
isChild?: boolean;
} }
function StepRow({ function StepRow({
@@ -115,8 +117,10 @@ function StepRow({
registerMeasureRef, registerMeasureRef,
onReorderStep, onReorderStep,
onReorderAction, onReorderAction,
isChild,
}: StepRowProps) { }: StepRowProps) {
// const step = item.step; // Removed local derivation // const step = item.step; // Removed local derivation
const allSteps = useDesignerStore((s) => s.steps);
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => { const displayActions = useMemo(() => {
@@ -149,9 +153,17 @@ function StepRow({
<div style={style} data-step-id={step.id}> <div style={style} data-step-id={step.id}>
<div <div
ref={(el) => registerMeasureRef(step.id, el)} ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4" className={cn(
"relative px-3 py-4 transition-all duration-300",
isChild && "ml-8 pl-0"
)}
data-step-id={step.id} data-step-id={step.id}
> >
{isChild && (
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
<CornerDownRight className="h-5 w-5" />
</div>
)}
<StepDroppableArea stepId={step.id} /> <StepDroppableArea stepId={step.id} />
<div <div
className={cn( className={cn(
@@ -281,6 +293,78 @@ function StepRow({
</div> </div>
</div> </div>
{/* Conditional Branching Visualization */}
{/* Conditional Branching Visualization */}
{step.type === "conditional" && (
<div className="mx-3 my-3 rounded-md border text-xs" style={{
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
borderColor: 'var(--validation-warning-border)', // Semantic border
}}>
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
borderColor: 'var(--validation-warning-border)',
color: 'var(--validation-warning-text)'
}}>
<GitBranch className="h-3.5 w-3.5" />
<span>Branching Logic</span>
</div>
<div className="p-2 space-y-2">
{!(step.trigger.conditions as any)?.options?.length ? (
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
No branches configured. Add options in properties.
</div>
) : (
(step.trigger.conditions as any).options.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = allSteps.find(s => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === 'number') {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-2">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 min-w-[70px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<span className="text-muted-foreground text-[10px]">then go to</span>
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge variant="secondary" className="px-1 py-0 h-4 text-[9px] min-w-[20px] justify-center tabular-nums">
#{targetIndex + 1}
</Badge>
)}
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
</div>
);
})
)}
</div>
</div>
)}
{/* Action List (Collapsible/Virtual content) */} {/* Action List (Collapsible/Virtual content) */}
{step.expanded && ( {step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8"> <div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
@@ -315,7 +399,7 @@ function StepRow({
)} )}
</div> </div>
</div> </div>
</div> </div >
); );
} }
@@ -787,6 +871,21 @@ export function FlowWorkspace({
return map; return map;
}, [steps]); }, [steps]);
/* Hierarchy detection for visual indentation */
const childStepIds = useMemo(() => {
const children = new Set<string>();
for (const step of steps) {
if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) {
for (const opt of (step.trigger.conditions as any).options) {
if (opt.nextStepId) {
children.add(opt.nextStepId);
}
}
}
}
return children;
}, [steps]);
/* Resize observer for viewport and width changes */ /* Resize observer for viewport and width changes */
useLayoutEffect(() => { useLayoutEffect(() => {
const el = containerRef.current; const el = containerRef.current;
@@ -1202,6 +1301,7 @@ export function FlowWorkspace({
registerMeasureRef={registerMeasureRef} registerMeasureRef={registerMeasureRef}
onReorderStep={handleReorderStep} onReorderStep={handleReorderStep}
onReorderAction={handleReorderAction} onReorderAction={handleReorderAction}
isChild={childStepIds.has(vi.step.id)}
/> />
), ),
)} )}

View File

@@ -203,8 +203,7 @@ function projectStepForDesign(
order: step.order, order: step.order,
trigger: { trigger: {
type: step.trigger.type, type: step.trigger.type,
// Only the sorted keys of conditions (structural presence) conditions: canonicalize(step.trigger.conditions),
conditionKeys: Object.keys(step.trigger.conditions).sort(),
}, },
actions: step.actions.map((a) => projectActionForDesign(a, options)), actions: step.actions.map((a) => projectActionForDesign(a, options)),
}; };
@@ -267,11 +266,35 @@ export async function computeDesignHash(
opts: DesignHashOptions = {}, opts: DesignHashOptions = {},
): Promise<string> { ): Promise<string> {
const options = { ...DEFAULT_OPTIONS, ...opts }; const options = { ...DEFAULT_OPTIONS, ...opts };
const projected = steps
.slice() // 1. Sort steps first to ensure order independence of input array
.sort((a, b) => a.order - b.order) const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
.map((s) => projectStepForDesign(s, options));
return hashObject({ steps: projected }); // 2. Map hierarchically (Merkle style)
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
// Action hashes
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
// Step hash
const pStep = {
id: s.id,
type: s.type,
order: s.order,
trigger: {
type: s.trigger.type,
conditions: canonicalize(s.trigger.conditions),
},
actions: actionHashes,
...(options.includeStepNames ? { name: s.name } : {}),
};
return hashObject(pStep);
}));
// 3. Aggregate design hash
return hashObject({
steps: stepHashes,
count: steps.length
});
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -338,7 +361,7 @@ export async function computeIncrementalDesignHash(
order: step.order, order: step.order,
trigger: { trigger: {
type: step.trigger.type, type: step.trigger.type,
conditionKeys: Object.keys(step.trigger.conditions).sort(), conditions: canonicalize(step.trigger.conditions),
}, },
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""), actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
...(options.includeStepNames ? { name: step.name } : {}), ...(options.includeStepNames ? { name: step.name } : {}),

View File

@@ -53,6 +53,7 @@ export interface ValidationResult {
// Parallel/conditional/loop execution happens at the ACTION level, not step level // Parallel/conditional/loop execution happens at the ACTION level, not step level
const VALID_STEP_TYPES: StepType[] = [ const VALID_STEP_TYPES: StepType[] = [
"sequential", "sequential",
"conditional",
]; ];
const VALID_TRIGGER_TYPES: TriggerType[] = [ const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start", "trial_start",
@@ -391,6 +392,34 @@ export function validateParameters(
} }
break; break;
case "array":
if (!Array.isArray(value)) {
issues.push({
severity: "error",
message: `Parameter '${paramDef.name}' must be a list/array`,
category: "parameter",
field,
stepId,
actionId,
suggestion: "Enter a list of values",
});
}
break;
case "json":
if (typeof value !== "object" || value === null) {
issues.push({
severity: "error",
message: `Parameter '${paramDef.name}' must be a valid object`,
category: "parameter",
field,
stepId,
actionId,
suggestion: "Enter a valid JSON object",
});
}
break;
default: default:
// Unknown parameter type // Unknown parameter type
issues.push({ issues.push({

View File

@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
export type Experiment = { export type Experiment = {
id: string; id: string;
@@ -78,29 +79,25 @@ const statusConfig = {
}; };
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) { function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
const handleDelete = async () => { const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
toast.success("Experiment deleted successfully");
utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Failed to delete experiment: ${error.message}`);
},
});
const handleDelete = () => {
if ( if (
window.confirm(`Are you sure you want to delete "${experiment.name}"?`) window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
) { ) {
try { deleteMutation.mutate({ id: experiment.id });
// TODO: Implement delete experiment mutation
toast.success("Experiment deleted successfully");
} catch {
toast.error("Failed to delete experiment");
}
} }
}; };
const handleCopyId = () => {
void navigator.clipboard.writeText(experiment.id);
toast.success("Experiment ID copied to clipboard");
};
const handleStartTrial = () => {
// Navigate to new trial creation with this experiment pre-selected
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -111,45 +108,20 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Eye className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
View Details Edit Metadata
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" /> <FlaskConical className="mr-2 h-4 w-4" />
Open Designer Design
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{experiment.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Experiment
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{experiment.status === "ready" && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start New Trial
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Experiment ID
</DropdownMenuItem>
{experiment.canDelete && ( {experiment.canDelete && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Experiment Delete
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
}, },
enableSorting: false, enableSorting: false,
}, },
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date as Date, { addSuffix: true })}
</div>
);
},
},
{ {
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: ({ column }) => ( header: ({ column }) => (

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
import { toast } from "~/components/ui/use-toast";
import { cn } from "~/lib/utils";
import axios from "axios";
interface ConsentUploadFormProps {
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function ConsentUploadForm({
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
}: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast({
title: "File too large",
description: "Maximum file size is 10MB",
variant: "destructive",
});
return;
}
// Validate type
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowedTypes.includes(selectedFile.type)) {
toast({
title: "Invalid file type",
description: "Please upload a PDF, PNG, or JPG file",
variant: "destructive",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(percentCompleted);
}
},
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast({
title: "Consent Recorded",
description: "The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast({
title: "Upload Failed",
description: error instanceof Error ? error.message : "An unexpected error occurred",
variant: "destructive",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
<p className="text-xs text-muted-foreground mb-4 text-center">
Drag and drop or click to select<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
Select File
</Button>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ConsentUploadForm } from "./ConsentUploadForm";
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
interface ParticipantConsentManagerProps {
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
}
export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
}: ParticipantConsentManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false }
);
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
return (
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-sm text-muted-foreground">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
</div>
{existingConsent && (
<p className="text-xs text-muted-foreground">
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Record Consent"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this participant.
{activeForm && (
<span className="block mt-1 font-medium text-foreground">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="py-4 text-center text-muted-foreground">
No active consent form found for this study. Please create one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}

View File

@@ -256,160 +256,148 @@ export function ParticipantForm({
<> <>
<FormSection <FormSection
title="Participant Information" title="Participant Information"
description="Basic information about the research participant." description="Basic identity and study association."
> >
<FormField> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Label htmlFor="participantCode">Participant Code *</Label> <FormField>
<Input <Label htmlFor="participantCode">Participant Code *</Label>
id="participantCode" <Input
{...form.register("participantCode")} id="participantCode"
placeholder="e.g., P001, SUBJ_01, etc." {...form.register("participantCode")}
className={ placeholder="e.g., P001"
form.formState.errors.participantCode ? "border-red-500" : "" className={
} form.formState.errors.participantCode ? "border-red-500" : ""
/> }
{form.formState.errors.participantCode && ( />
<p className="text-sm text-red-600"> {form.formState.errors.participantCode && (
{form.formState.errors.participantCode.message} <p className="text-sm text-red-600">
</p> {form.formState.errors.participantCode.message}
)} </p>
<p className="text-muted-foreground text-xs"> )}
Unique identifier for this participant within the study </FormField>
</p>
</FormField>
<FormField> <FormField>
<Label htmlFor="name">Full Name</Label> <Label htmlFor="name">Full Name</Label>
<Input <Input
id="name" id="name"
{...form.register("name")} {...form.register("name")}
placeholder="Optional: Participant's full name" placeholder="Optional name"
className={form.formState.errors.name ? "border-red-500" : ""} className={form.formState.errors.name ? "border-red-500" : ""}
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.name.message} {form.formState.errors.name.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: Real name for contact purposes
</p>
</FormField>
<FormField> <FormField>
<Label htmlFor="email">Email Address</Label> <Label htmlFor="email">Email Address</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
{...form.register("email")} {...form.register("email")}
placeholder="participant@example.com" placeholder="participant@example.com"
className={form.formState.errors.email ? "border-red-500" : ""} className={form.formState.errors.email ? "border-red-500" : ""}
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.email.message} {form.formState.errors.email.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: For scheduling and communication </div>
</p>
</FormField>
<FormField>
<Label htmlFor="studyId">Study *</Label>
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectTrigger
className={form.formState.errors.studyId ? "border-red-500" : ""}
>
<SelectValue
placeholder={
studiesLoading ? "Loading studies..." : "Select a study"
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Study cannot be changed after registration
</p>
)}
</FormField>
</FormSection> </FormSection>
<FormSection <div className="my-6" />
title="Demographics"
description="Optional demographic information for research purposes."
>
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Age in years (minimum 18)
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="gender">Gender</Label> title="Demographics & Study"
<Select description="study association and demographic details."
value={form.watch("gender") ?? ""} >
onValueChange={(value) => <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
form.setValue( <FormField>
"gender", <Label htmlFor="studyId">Study *</Label>
value as <Select
| "male" value={form.watch("studyId")}
| "female" onValueChange={(value) => form.setValue("studyId", value)}
| "non_binary" disabled={studiesLoading || mode === "edit"}
| "prefer_not_to_say" >
| "other", <SelectTrigger
) className={
} form.formState.errors.studyId ? "border-red-500" : ""
> }
<SelectTrigger> >
<SelectValue placeholder="Select gender (optional)" /> <SelectValue
</SelectTrigger> placeholder={
<SelectContent> studiesLoading ? "Loading..." : "Select study"
<SelectItem value="male">Male</SelectItem> }
<SelectItem value="female">Female</SelectItem> />
<SelectItem value="non_binary">Non-binary</SelectItem> </SelectTrigger>
<SelectItem value="prefer_not_to_say"> <SelectContent>
Prefer not to say {studiesData?.studies?.map((study) => (
</SelectItem> <SelectItem key={study.id} value={study.id}>
<SelectItem value="other">Other</SelectItem> {study.name}
</SelectContent> </SelectItem>
</Select> ))}
<p className="text-muted-foreground text-xs"> </SelectContent>
Optional: Gender identity for demographic analysis </Select>
</p> {form.formState.errors.studyId && (
</FormField> <p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</FormField>
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
</FormField>
<FormField>
<Label htmlFor="gender">Gender</Label>
<Select
value={form.watch("gender") ?? ""}
onValueChange={(value) =>
form.setValue(
"gender",
value as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
<SelectItem value="non_binary">Non-binary</SelectItem>
<SelectItem value="prefer_not_to_say">
Prefer not to say
</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
</FormSection> </FormSection>
{mode === "create" && ( {mode === "create" && (
@@ -505,8 +493,7 @@ export function ParticipantForm({
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={mode === "create" ? sidebar : undefined}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
> >
{formFields} {formFields}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -24,6 +24,12 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
export type Participant = { export type Participant = {
id: string; id: string;
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
const name = row.getValue("name"); const name = row.getValue("name");
const email = row.original.email; const email = row.original.email;
return ( return (
<div> <TooltipProvider>
<div className="truncate font-medium"> <div>
{String(name) || "No name provided"} <div className="truncate font-medium max-w-[200px]">
</div> <Tooltip>
{email && ( <TooltipTrigger asChild>
<div className="text-muted-foreground truncate text-sm"> <span>{String(name) || "No name provided"}</span>
{email} </TooltipTrigger>
<TooltipContent>
<p>{String(name) || "No name provided"}</p>
</TooltipContent>
</Tooltip>
</div> </div>
)} {email && (
</div> <div className="text-muted-foreground truncate text-sm max-w-[200px]">
<Tooltip>
<TooltipTrigger asChild>
<span>{email}</span>
</TooltipTrigger>
<TooltipContent>
<p>{email}</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
); );
}, },
}, },
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => { cell: ({ row }) => {
const consentGiven = row.getValue("consentGiven"); const consentGiven = row.getValue("consentGiven");
if (consentGiven) { return (
return <Badge className="bg-green-100 text-green-800">Consented</Badge>; <TooltipProvider>
} <Tooltip>
<TooltipTrigger>
return <Badge className="bg-red-100 text-red-800">Pending</Badge>; {consentGiven ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-200">
Consented
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 hover:bg-red-200">
Pending
</Badge>
)}
</TooltipTrigger>
<TooltipContent>
<p>
{consentGiven
? "Participant has signed the consent form."
: "Consent form has not been recorded."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}, },
}, },
{ {
@@ -148,30 +189,7 @@ export const columns: ColumnDef<Participant>[] = [
); );
}, },
}, },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
@@ -195,23 +213,12 @@ export const columns: ColumnDef<Participant>[] = [
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}> <Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit participant Edit participant
</Link > </Link >
</DropdownMenuItem > </DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
EntityForm, EntityForm,
@@ -165,103 +166,124 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
// Form fields // Form fields
const formFields = ( const formFields = (
<FormSection <div className="space-y-6">
title="Study Details" <FormSection
description="Basic information about your research study." title="Study Details"
> description="Basic information and status of your research study."
<FormField> >
<Label htmlFor="tour-study-name">Study Name *</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input <FormField>
id="tour-study-name" <Label htmlFor="tour-study-name">Study Name *</Label>
{...form.register("name")} <Input
placeholder="Enter study name..." id="tour-study-name"
className={form.formState.errors.name ? "border-red-500" : ""} {...form.register("name")}
/> placeholder="Enter study name..."
{form.formState.errors.name && ( className={form.formState.errors.name ? "border-red-500" : ""}
<p className="text-sm text-red-600"> />
{form.formState.errors.name.message} {form.formState.errors.name && (
</p> <p className="text-sm text-red-600">
)} {form.formState.errors.name.message}
</FormField> </p>
)}
</FormField>
<FormField> <FormField>
<Label htmlFor="tour-study-description">Description *</Label> <Label htmlFor="status">Status</Label>
<Textarea <Select
id="tour-study-description" value={form.watch("status")}
{...form.register("description")} onValueChange={(value) =>
placeholder="Describe the research objectives, methodology, and expected outcomes..." form.setValue(
rows={4} "status",
className={form.formState.errors.description ? "border-red-500" : ""} value as "draft" | "active" | "completed" | "archived",
/> )
{form.formState.errors.description && ( }
<p className="text-sm text-red-600"> >
{form.formState.errors.description.message} <SelectTrigger>
</p> <SelectValue placeholder="Select status" />
)} </SelectTrigger>
</FormField> <SelectContent>
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">
Archived - Study concluded
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField> <div className="md:col-span-2">
<Label htmlFor="institution">Institution *</Label> <FormField>
<Input <Label htmlFor="tour-study-description">Description *</Label>
id="institution" <Textarea
{...form.register("institution")} id="tour-study-description"
placeholder="e.g., University of Technology" {...form.register("description")}
className={form.formState.errors.institution ? "border-red-500" : ""} placeholder="Describe the research objectives, methodology, and expected outcomes..."
/> rows={4}
{form.formState.errors.institution && ( className={
<p className="text-sm text-red-600"> form.formState.errors.description ? "border-red-500" : ""
{form.formState.errors.institution.message} }
</p> />
)} {form.formState.errors.description && (
</FormField> <p className="text-sm text-red-600">
{form.formState.errors.description.message}
</p>
)}
</FormField>
</div>
</div>
</FormSection>
<FormField> <Separator />
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
<Input
id="irbProtocolNumber"
{...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="status">Status</Label> title="Configuration"
<Select description="Institutional details and ethics approval."
value={form.watch("status")} >
onValueChange={(value) => <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
form.setValue( <FormField>
"status", <Label htmlFor="institution">Institution *</Label>
value as "draft" | "active" | "completed" | "archived", <Input
) id="institution"
} {...form.register("institution")}
> placeholder="e.g., University of Technology"
<SelectTrigger> className={
<SelectValue placeholder="Select status" /> form.formState.errors.institution ? "border-red-500" : ""
</SelectTrigger> }
<SelectContent> />
<SelectItem value="draft">Draft - Study in preparation</SelectItem> {form.formState.errors.institution && (
<SelectItem value="active"> <p className="text-sm text-red-600">
Active - Currently recruiting/running {form.formState.errors.institution.message}
</SelectItem> </p>
<SelectItem value="completed"> )}
Completed - Data collection finished </FormField>
</SelectItem>
<SelectItem value="archived">Archived - Study concluded</SelectItem> <FormField>
</SelectContent> <Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
</Select> <Input
</FormField> id="irbProtocolNumber"
</FormSection> {...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
</div>
</FormSection>
</div>
); );
// Sidebar content // Sidebar content
@@ -324,7 +346,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={sidebar} sidebar={mode === "create" ? sidebar : undefined}
submitButtonId="tour-study-submit" submitButtonId="tour-study-submit"
extraActions={ extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}> <Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react"; import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
@@ -331,33 +331,7 @@ export const columns: ColumnDef<Trial>[] = [
); );
}, },
}, },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
if (!date)
return <span className="text-muted-foreground text-sm">Unknown</span>;
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(trial.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
{trial.status === "scheduled" && ( {trial.status === "scheduled" && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}> <Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{(trial.status === "scheduled" || trial.status === "failed") && ( {(trial.status === "scheduled" || trial.status === "failed") && (
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
<Ban className="mr-2 h-4 w-4" /> <Ban className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,107 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
// Define the shape of our data (matching schema)
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col">
<span className="font-mono font-medium">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
},
{
accessorKey: "eventType",
header: "Event Type",
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention) Icon = User; // Wizard/Intervention often User
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle;
return (
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
// Simplistic view for now: JSON stringify but truncated?
// Or meaningful extraction based on event type.
return (
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
);
},
},
];

View File

@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { DataTable } from "~/components/ui/data-table";
import { type TrialEvent, eventsColumns } from "./events-columns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
// Type filter
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
return false;
}
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch;
}
return true;
});
}, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI
const filters = (
<div className="flex items-center gap-2">
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
</SelectContent>
</Select>
</div>
);
return (
<div className="space-y-4">
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
Since we implemented custom external filtering for "type" dropdown and "global" search,
we pass the filtered data directly.
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
DataTable's internal search or pass a custom filter.
The shared DataTable's `searchKey` only filters a specific column string value.
Since "data" is an object, we can't easily use the built-in single-column search.
So we'll implement our own search input and pass `filters={filters}` which creates
additional dropdowns, but we might want to REPLACE the standard search input.
Looking at `DataTable` implementation:
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
no input is rendered, and we can put ours in `filters`.
*/}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{filters}
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
</div>
);
}

View File

@@ -2,17 +2,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react"; import { Button } from "~/components/ui/button";
import Link from "next/link";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { PlaybackProvider } from "../playback/PlaybackContext"; import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer"; import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline"; import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "~/components/ui/resizable"; } from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps { interface TrialAnalysisViewProps {
trial: { trial: {
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
mediaCount?: number; mediaCount?: number;
media?: { url: string; contentType: string }[]; media?: { url: string; contentType: string }[];
}; };
backHref: string;
} }
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) { export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
// Fetch events for timeline // Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({ const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id, trialId: trial.id,
@@ -39,139 +44,153 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/")); const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url; const videoUrl = videoMedia?.url;
// Metrics
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
const errorCount = events.filter(e => e.eventType.includes("error")).length;
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
return ( return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}> <PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden"> <div className="flex h-full flex-col gap-4 p-4 text-sm">
{/* Header Context */} {/* Header Context */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14"> <div className="flex items-center justify-between pb-2 border-b">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild className="-ml-2">
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-base font-semibold leading-none"> <h1 className="text-lg font-semibold leading-none tracking-tight">
{trial.experiment.name} {trial.experiment.name}
</h1> </h1>
<p className="text-xs text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-muted-foreground mt-1">
{trial.participant.participantCode} Session {trial.id.slice(0, 4)}... <span className="font-mono">{trial.participant.participantCode}</span>
</p> <span></span>
</div> <span>Session {trial.id.slice(0, 4)}</span>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
</div> </div>
{trial.duration && ( </div>
<Badge variant="secondary" className="text-[10px] font-mono"> </div>
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s <div className="flex items-center gap-4">
</Badge> <div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
)} <Clock className="h-3.5 w-3.5" />
<span className="text-xs font-mono">
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Main Resizable Workspace */} {/* Metrics Header */}
<div className="flex-1 min-h-0"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<ResizablePanelGroup direction="horizontal"> <Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
<Clock className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? (
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
) : (
"--:--"
)}
</div>
<p className="text-xs text-muted-foreground">Total session time</p>
</CardContent>
</Card>
{/* LEFT: Video & Timeline */} <Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0"> <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<ResizablePanelGroup direction="vertical"> <CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
{/* Top: Video Player */} <Bot className="h-4 w-4 text-purple-500" />
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative"> </CardHeader>
{videoUrl ? ( <CardContent>
<div className="absolute inset-0"> <div className="text-2xl font-bold">{robotActionCount}</div>
<PlaybackPlayer src={videoUrl} /> <p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
</div> </CardContent>
) : ( </Card>
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
<p className="text-sm">No recording available.</p>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle /> <Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
<AlertTriangle className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{interventionCount}</div>
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
</CardContent>
</Card>
{/* Bottom: Timeline Track */} <Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0"> <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
<Info className="h-3 w-3 text-muted-foreground" /> <Activity className="h-4 w-4 text-green-500" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span> </CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
</div>
</CardContent>
</Card>
</div>
{/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
<ResizablePanelGroup direction="vertical">
{/* TOP: Video & Timeline */}
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40">
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{videoUrl ? (
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div> </div>
<div className="flex-1 min-h-0 relative"> ) : (
<div className="absolute inset-0 p-2 overflow-hidden"> <div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
<EventTimeline /> <div className="bg-muted rounded-full p-4 mb-4">
<VideoOff className="h-8 w-8 opacity-50" />
</div> </div>
<h3 className="font-semibold text-lg">No playback media available</h3>
<p className="text-sm max-w-sm mt-2">
There is no video recording associated with this trial session.
</p>
</div> </div>
</ResizablePanel> )}
</ResizablePanelGroup> </div>
{/* Timeline Control */}
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
<EventTimeline />
</div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle className="bg-border/50" />
{/* RIGHT: Logs & Metrics */} {/* BOTTOM: Events Table */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5"> <ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background">
{/* Metrics Strip */} <div className="flex items-center justify-between px-4 py-3 border-b">
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none"> <div className="flex items-center gap-2">
<Card className="shadow-none border-dashed bg-transparent"> <FileText className="h-4 w-4 text-primary" />
<CardContent className="p-3 py-2"> <h3 className="font-semibold text-sm">Event Log</h3>
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div> </div>
<div className="text-xl font-mono font-bold flex items-center gap-2"> <Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
</div>
</CardContent>
</Card>
</div>
{/* Log Title */}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{!!event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
</div>
</ScrollArea>
</div> </div>
<ScrollArea className="flex-1">
<div className="p-4">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
const s = Math.floor(totalSeconds % 60); const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }

View File

@@ -29,12 +29,13 @@ interface WizardViewProps {
demographics: Record<string, unknown> | null; demographics: Record<string, unknown> | null;
}; };
}; };
userRole: string;
} }
export function WizardView({ trial }: WizardViewProps) { export function WizardView({ trial, userRole }: WizardViewProps) {
return ( return (
<div className="h-full"> <div className="h-full max-h-full w-full overflow-hidden">
<WizardInterface trial={trial} userRole="wizard" /> <WizardInterface trial={trial} userRole={userRole} />
</div> </div>
); );
} }

View File

@@ -155,7 +155,7 @@ export function RobotActionsPanel({
disconnect: disconnectRos, disconnect: disconnectRos,
executeRobotAction: executeRosAction, executeRobotAction: executeRosAction,
} = useWizardRos({ } = useWizardRos({
autoConnect: true, autoConnect: false, // Let WizardInterface handle connection
onActionCompleted: (execution) => { onActionCompleted: (execution) => {
toast.success(`Completed: ${execution.actionId}`, { toast.success(`Completed: ${execution.actionId}`, {
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`, description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,

View File

@@ -1,21 +1,35 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react"; import {
Play,
CheckCircle,
X,
Clock,
AlertCircle,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
ChevronDown,
ChevronUp,
Pause,
SkipForward
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header";
import Link from "next/link";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { WizardControlPanel } from "./panels/WizardControlPanel"; import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane"; import { WizardObservationPane } from "./panels/WizardObservationPane";
import { import { TrialStatusBar } from "./panels/TrialStatusBar";
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -68,8 +82,18 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
nextStepId?: string;
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions: ActionData[]; actions: ActionData[];
} }
@@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const router = useRouter(); const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders // UI State
const [controlPanelTab, setControlPanelTab] = useState< const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
"control" | "step" | "actions" | "robot" const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
>("control");
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false); const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState< const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events" "status" | "robot" | "events"
>("status"); >("status");
const [completedActionsCount, setCompletedActionsCount] = useState(0); const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Collapse state for panels
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [obsCollapsed, setObsCollapsed] = useState(false);
// Center tabs (Timeline | Actions)
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
// Reset completed actions when step changes // Reset completed actions when step changes
useEffect(() => { useEffect(() => {
setCompletedActionsCount(0); setCompletedActionsCount(0);
}, [currentStepIndex]); }, [currentStepIndex]);
// Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null);
// Get experiment steps from API // Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery( const { data: experimentSteps } = api.experiments.getSteps.useQuery(
{ experimentId: trial.experimentId }, { experimentId: trial.experimentId },
@@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({
case "parallel": case "parallel":
return "parallel_steps" as const; return "parallel_steps" as const;
case "conditional": case "conditional":
return "conditional_branch" as const; return "conditional" as const;
default: default:
return "wizard_action" as const; return "wizard_action" as const;
} }
@@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({
name: step.name ?? `Step ${index + 1}`, name: step.name ?? `Step ${index + 1}`,
description: step.description, description: step.description,
type: mapStepType(step.type), type: mapStepType(step.type),
// Fix: Conditions are at root level from API
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
parameters: step.parameters ?? {}, parameters: step.parameters ?? {},
order: step.order ?? index, order: step.order ?? index,
actions: step.actions?.map((action) => ({ actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
id: action.id, id: action.id,
name: action.name, name: action.name,
description: action.description, description: action.description,
@@ -373,6 +406,32 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
const pauseTrialMutation = api.trials.pause.useMutation({
onSuccess: () => {
toast.success("Trial paused");
// Optionally update local state if needed, though status might not change on backend strictly to "paused"
// depending on enum. But we logged the event.
},
onError: (error) => {
toast.error("Failed to pause trial", { description: error.message });
},
});
const archiveTrialMutation = api.trials.archive.useMutation({
onSuccess: () => {
console.log("Trial archived successfully");
},
onError: (error) => {
console.error("Failed to archive trial", error);
},
});
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => {
// toast.success("Event logged"); // Too noisy
},
});
// Action handlers // Action handlers
const handleStartTrial = async () => { const handleStartTrial = async () => {
console.log( console.log(
@@ -410,21 +469,88 @@ export const WizardInterface = React.memo(function WizardInterface({
}; };
const handlePauseTrial = async () => { const handlePauseTrial = async () => {
// TODO: Implement pause functionality try {
console.log("Pause trial"); await pauseTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to pause trial:", error);
}
}; };
const handleNextStep = () => { const handleNextStep = (targetIndex?: number) => {
if (currentStepIndex < steps.length - 1) { // If explicit target provided (from branching choice), use it
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues if (typeof targetIndex === 'number') {
setCurrentStepIndex(currentStepIndex + 1); // Find step by index to ensure safety
// Note: Step transitions can be enhanced later with database logging if (targetIndex >= 0 && targetIndex < steps.length) {
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
setCompletedActionsCount(0);
setCurrentStepIndex(targetIndex);
setLastResponse(null);
return;
}
}
// Dynamic Branching Logic
const currentStep = steps[currentStepIndex];
// Check if we have a stored response that dictates the next step
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
if (matchedOption && matchedOption.nextStepId) {
// Find index of the target step
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
setCurrentStepIndex(targetIndex);
setLastResponse(null); // Reset after consuming
return;
}
}
}
// Check for explicit nextStepId in conditions (e.g. for end of branch)
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
if (currentStep?.conditions?.nextStepId) {
const nextId = String(currentStep.conditions.nextStepId);
const targetIndex = steps.findIndex(s => s.id === nextId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0);
return;
} else {
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
}
} else {
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
}
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Log step change
logEventMutation.mutate({
trialId: trial.id,
type: "step_changed",
data: {
fromIndex: currentStepIndex,
toIndex: nextIndex,
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
}
});
setCurrentStepIndex(nextIndex);
} else {
handleCompleteTrial();
} }
}; };
const handleCompleteTrial = async () => { const handleCompleteTrial = async () => {
try { try {
await completeTrialMutation.mutateAsync({ id: trial.id }); await completeTrialMutation.mutateAsync({ id: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
} catch (error) { } catch (error) {
console.error("Failed to complete trial:", error); console.error("Failed to complete trial:", error);
} }
@@ -461,10 +587,7 @@ export const WizardInterface = React.memo(function WizardInterface({
}); });
}; };
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions // Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({ const addInterventionMutation = api.trials.addIntervention.useMutation({
@@ -476,8 +599,25 @@ export const WizardInterface = React.memo(function WizardInterface({
parameters?: Record<string, unknown>, parameters?: Record<string, unknown>,
) => { ) => {
try { try {
// Log action execution
console.log("Executing action:", actionId, parameters); console.log("Executing action:", actionId, parameters);
// Handle branching logic (wizard_wait_for_response)
if (parameters?.value && parameters?.label) {
setLastResponse(String(parameters.value));
// If nextStepId is provided, jump immediately
if (parameters.nextStepId) {
const nextId = String(parameters.nextStepId);
const targetIndex = steps.findIndex(s => s.id === nextId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
handleNextStep(targetIndex);
return; // Exit after jump
}
}
}
if (actionId === "acknowledge") { if (actionId === "acknowledge") {
await logEventMutation.mutateAsync({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
@@ -614,65 +754,102 @@ export const WizardInterface = React.memo(function WizardInterface({
[logRobotActionMutation, trial.id], [logRobotActionMutation, trial.id],
); );
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
{/* Compact Status Bar */} <PageHeader
<div className="bg-background border-b px-4 py-2"> title="Trial Execution"
<div className="flex items-center justify-between"> description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
<div className="flex items-center gap-4"> icon={Play}
<Badge actions={
variant={statusConfig.variant} <div className="flex items-center gap-2">
className="flex items-center gap-1" {trial.status === "scheduled" && (
> <Button
<StatusIcon className="h-3 w-3" /> onClick={handleStartTrial}
{trial.status.replace("_", " ")} size="sm"
</Badge> className="gap-2"
>
<Play className="h-4 w-4" />
Start Trial
</Button>
)}
{trial.status === "in_progress" && ( {trial.status === "in_progress" && (
<div className="flex items-center gap-1 font-mono text-sm"> <>
<Clock className="h-3 w-3" /> <Button
{formatElapsedTime(elapsedTime)} variant="outline"
</div> size="sm"
onClick={handlePauseTrial}
className="gap-2"
>
<Pause className="h-4 w-4" />
Pause
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleNextStep()}
className="gap-2"
>
<SkipForward className="h-4 w-4" />
Next Step
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleAbortTrial}
className="gap-2"
>
<X className="h-4 w-4" />
Abort
</Button>
<Button
variant="default"
size="sm"
onClick={handleCompleteTrial}
className="gap-2 bg-green-600 hover:bg-green-700"
>
<CheckCircle className="h-4 w-4" />
Complete
</Button>
</>
)} )}
{steps.length > 0 && ( {_userRole !== "participant" && (
<div className="flex items-center gap-2 text-sm"> <Button asChild variant="ghost" size="sm">
<span className="text-muted-foreground"> <Link href={`/studies/${trial.experiment.studyId}/trials`}>
Step {currentStepIndex + 1} of {totalSteps} Exit
</span> </Link>
<div className="w-16"> </Button>
<Progress value={progressPercentage} className="h-2" />
</div>
</div>
)} )}
</div> </div>
}
className="flex-none px-2 pb-2"
/>
<div className="text-muted-foreground flex items-center gap-4 text-sm"> {/* Main Grid - 2 rows */}
<div>{trial.experiment.name}</div> <div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
<div>{trial.participant.participantCode}</div> {/* Top Row - 3 Column Layout */}
<Badge <div className="flex-1 min-h-0 flex gap-2">
variant={rosConnected ? "default" : "outline"} {/* Left Sidebar - Control Panel (Collapsible) */}
className="text-xs" {!leftCollapsed && (
> <div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
{rosConnected ? "ROS Connected" : "ROS Offline"} <div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
</Badge> <span className="text-sm font-medium">Control</span>
<button <Button
onClick={() => startTour("wizard")} variant="ghost"
className="hover:bg-muted p-1 rounded-full transition-colors" size="icon"
title="Start Tour" className="h-6 w-6"
> onClick={() => setLeftCollapsed(true)}
<HelpCircle className="h-4 w-4" /> >
</button> <PanelLeftClose className="h-4 w-4" />
</div> </Button>
</div> </div>
</div> <div className="flex-1 overflow-auto min-h-0 bg-muted/10">
{/* Main Content with Vertical Resizable Split */}
<div className="min-h-0 flex-1">
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer
left={
<div id="tour-wizard-controls" className="h-full"> <div id="tour-wizard-controls" className="h-full">
<WizardControlPanel <WizardControlPanel
trial={trial} trial={trial}
@@ -688,38 +865,98 @@ export const WizardInterface = React.memo(function WizardInterface({
onExecuteRobotAction={handleExecuteRobotAction} onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId} studyId={trial.experiment.studyId}
_isConnected={rosConnected} _isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending} isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife} onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</div> </div>
} </div>
center={ </div>
<div id="tour-wizard-timeline" className="h-full"> )}
<WizardExecutionPanel
trial={trial} {/* Center - Tabbed Workspace */}
currentStep={currentStep} {/* Center - Execution Workspace */}
steps={steps} <div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
currentStepIndex={currentStepIndex} <div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
trialEvents={trialEvents} {leftCollapsed && (
onStepSelect={(index: number) => setCurrentStepIndex(index)} <Button
onExecuteAction={handleExecuteAction} variant="ghost"
onExecuteRobotAction={handleExecuteRobotAction} size="icon"
activeTab={executionPanelTab} className="h-6 w-6 mr-2"
onTabChange={setExecutionPanelTab} onClick={() => setLeftCollapsed(false)}
onSkipAction={handleSkipAction} title="Open Tools Panel"
isExecuting={isExecutingAction} >
onNextStep={handleNextStep} <PanelLeftOpen className="h-4 w-4" />
completedActionsCount={completedActionsCount} </Button>
onActionCompleted={() => setCompletedActionsCount(c => c + 1)} )}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'} <div className="flex items-center gap-2">
/> <span className="text-sm font-medium">Trial Execution</span>
</div> {currentStep && (
} <Badge variant="outline" className="text-xs font-normal">
right={ {currentStep.name}
</Badge>
)}
</div>
<div className="flex-1" />
<div className="mr-2 text-xs text-muted-foreground font-medium">
Step {currentStepIndex + 1} / {steps.length}
</div>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(false)}
title="Open Robot Status"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
</div>
</div>
{/* Right Sidebar - Robot Status (Collapsible) */}
{!rightCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Robot Status</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-robot-status" className="h-full"> <div id="tour-wizard-robot-status" className="h-full">
<WizardMonitoringPanel <WizardMonitoringPanel
rosConnected={rosConnected} rosConnected={rosConnected}
@@ -732,27 +969,56 @@ export const WizardInterface = React.memo(function WizardInterface({
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</div> </div>
} </div>
showDividers={true} </div>
className="h-full" )}
/> </div>
</ResizablePanel>
<ResizableHandle /> {/* Bottom Row - Observations (Full Width, Collapsible) */}
{!obsCollapsed && (
<ResizablePanel defaultSize={25} minSize={10}> <Tabs value={obsTab} onValueChange={(v) => setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
<WizardObservationPane <div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
onAddAnnotation={handleAddAnnotation} <span className="text-sm font-medium">Observations</span>
isSubmitting={addAnnotationMutation.isPending} <TabsList className="h-7 bg-transparent border-0 p-0">
trialEvents={trialEvents} <TabsTrigger value="notes" className="text-xs h-7 px-3">Notes</TabsTrigger>
// Observation pane is where observers usually work, so not readOnly for them? <TabsTrigger value="timeline" className="text-xs h-7 px-3">Timeline</TabsTrigger>
// But maybe we want 'readOnly' for completed trials. </TabsList>
readOnly={trial.status === 'completed'} <div className="flex-1" />
/> <Button
</ResizablePanel> variant="ghost"
</ResizablePanelGroup> size="icon"
</div> className="h-6 w-6"
</div> onClick={() => setObsCollapsed(true)}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
activeTab={obsTab}
/>
</div>
</Tabs>
)}
{
obsCollapsed && (
<Button
variant="outline"
size="sm"
onClick={() => setObsCollapsed(false)}
className="w-full flex-none"
>
<ChevronUp className="h-4 w-4 mr-2" />
Show Observations
</Button>
)
}
</div >
</div >
); );
}); });

View File

@@ -0,0 +1,133 @@
"use client";
import React, { useMemo } from "react";
import {
GitBranch,
Sparkles,
CheckCircle2,
Clock,
Play,
StickyNote,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils";
import { Progress } from "~/components/ui/progress";
export interface TrialStatusBarProps {
currentStepIndex: number;
totalSteps: number;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
rosConnected: boolean;
eventsCount: number;
completedActionsCount: number;
totalActionsCount: number;
onAddNote?: () => void;
className?: string;
}
export function TrialStatusBar({
currentStepIndex,
totalSteps,
trialStatus,
rosConnected,
eventsCount,
completedActionsCount,
totalActionsCount,
onAddNote,
className,
}: TrialStatusBarProps) {
const progressPercentage = useMemo(
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
[currentStepIndex, totalSteps],
);
const actionProgress = useMemo(
() =>
totalActionsCount > 0
? (completedActionsCount / totalActionsCount) * 100
: 0,
[completedActionsCount, totalActionsCount],
);
return (
<div
className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
className,
)}
>
{/* Step Progress */}
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 opacity-70" />
Step {currentStepIndex + 1}/{totalSteps}
</span>
<div className="w-20">
<Progress value={progressPercentage} className="h-1.5" />
</div>
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="flex items-center gap-3 text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
<Play className="h-2.5 w-2.5" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
</div>
);
}
export default TrialStatusBar;

View File

@@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
)} )}
</div> </div>
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative"> <div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
{isCameraEnabled ? ( {isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800"> <div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
<AspectRatio ratio={16 / 9}> <AspectRatio ratio={16 / 9}>
<Webcam <Webcam
ref={webcamRef} ref={webcamRef}
@@ -249,11 +249,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
)} )}
</div> </div>
) : ( ) : (
<div className="text-center text-slate-500"> <div className="text-center text-muted-foreground/50">
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" /> <div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<p className="text-sm">Camera is disabled</p> <CameraOff className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm font-medium">Camera is disabled</p>
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
className="mt-4" className="mt-4"
onClick={handleEnableCamera} onClick={handleEnableCamera}

View File

@@ -22,7 +22,6 @@ import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { RobotActionsPanel } from "../RobotActionsPanel"; import { RobotActionsPanel } from "../RobotActionsPanel";
@@ -34,8 +33,17 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional"; // Updated to match DB enum
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions?: { actions?: {
id: string; id: string;
@@ -80,7 +88,7 @@ interface WizardControlPanelProps {
currentStepIndex: number; currentStepIndex: number;
onStartTrial: () => void; onStartTrial: () => void;
onPauseTrial: () => void; onPauseTrial: () => void;
onNextStep: () => void; onNextStep: (targetIndex?: number) => void;
onCompleteTrial: () => void; onCompleteTrial: () => void;
onAbortTrial: () => void; onAbortTrial: () => void;
onExecuteAction: ( onExecuteAction: (
@@ -94,14 +102,13 @@ interface WizardControlPanelProps {
) => Promise<void>; ) => Promise<void>;
studyId?: string; studyId?: string;
_isConnected: boolean; _isConnected: boolean;
activeTab: "control" | "step" | "actions" | "robot";
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean; isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>; onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean; readOnly?: boolean;
} }
export function WizardControlPanel({ export const WizardControlPanel = React.memo(function WizardControlPanel({
trial, trial,
currentStep, currentStep,
steps, steps,
@@ -115,8 +122,6 @@ export function WizardControlPanel({
onExecuteRobotAction, onExecuteRobotAction,
studyId, studyId,
_isConnected, _isConnected,
activeTab,
onTabChange,
isStarting = false, isStarting = false,
onSetAutonomousLife, onSetAutonomousLife,
readOnly = false, readOnly = false,
@@ -141,331 +146,111 @@ export function WizardControlPanel({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Tabbed Content */}
<Tabs
value={activeTab}
onValueChange={(value: string) => {
if (
value === "control" ||
value === "step" ||
value === "actions" ||
value === "robot"
) {
onTabChange(value as "control" | "step" | "actions");
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-2 py-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="control" className="text-xs">
<Play className="mr-1 h-3 w-3" />
Control
</TabsTrigger>
<TabsTrigger value="step" className="text-xs">
<Eye className="mr-1 h-3 w-3" />
Step
</TabsTrigger>
<TabsTrigger value="actions" className="text-xs">
<Zap className="mr-1 h-3 w-3" />
Actions
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1">
{/* Trial Control Tab */} <div className="min-h-0 flex-1">
<TabsContent <ScrollArea className="h-full">
value="control" <div className="space-y-4 p-3">
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col" <div className="space-y-3">
> {/* Decision Point UI removed as per user request (handled in Execution Panel) */}
<ScrollArea className="h-full">
<div className="space-y-3 p-3"> {trial.status === "in_progress" ? (
{trial.status === "scheduled" && ( <div className="space-y-2">
<Button <Button
onClick={() => { variant="outline"
console.log("[WizardControlPanel] Start Trial clicked");
onStartTrial();
}}
className="w-full"
size="sm" size="sm"
disabled={isStarting || readOnly} className="w-full justify-start"
onClick={() => onExecuteAction("acknowledge")}
disabled={readOnly}
> >
<Play className="mr-2 h-4 w-4" /> <CheckCircle className="mr-2 h-3 w-3" />
{isStarting ? "Starting..." : "Start Trial"} Acknowledge
</Button> </Button>
)}
{trial.status === "in_progress" && ( <Button
<div className="space-y-2"> variant="outline"
<div className="grid grid-cols-2 gap-2"> size="sm"
<Button className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={onPauseTrial} onClick={() => onExecuteAction("intervene")}
variant="outline" disabled={readOnly}
size="sm" >
disabled={readOnly} <AlertCircle className="mr-2 h-3 w-3" />
> Flag Intervention
<Pause className="mr-1 h-3 w-3" /> </Button>
Pause
</Button>
<Button
onClick={onNextStep}
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
Next
</Button>
</div>
<Separator /> <Button
variant="outline"
<Button size="sm"
onClick={onCompleteTrial} className="w-full justify-start"
variant="outline" onClick={() => onExecuteAction("note", { content: "Wizard note" })}
className="w-full" disabled={readOnly}
size="sm" >
disabled={readOnly} <User className="mr-2 h-3 w-3" />
> Add Note
<CheckCircle className="mr-2 h-4 w-4" /> </Button>
Complete Trial
</Button>
<Button
onClick={onAbortTrial}
variant="destructive"
className="w-full"
size="sm"
disabled={readOnly}
>
<X className="mr-2 h-4 w-4" />
Abort Trial
</Button>
</div>
)}
{(trial.status === "completed" ||
trial.status === "aborted") && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Trial has ended. All controls are disabled.
</AlertDescription>
</Alert>
)}
<Separator />
<div className="space-y-4">
<div className="text-xs font-medium">Robot Status</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Connection
</span>
{_isConnected ? (
<Badge variant="default" className="bg-green-600 text-xs">
Connected
</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
Polling...
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
</div>
<Switch
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Current Step Tab */}
<TabsContent
value="step"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="p-3">
{currentStep && trial.status === "in_progress" ? (
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{currentStep.name}
</div>
<Badge variant="outline" className="text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
</div>
{currentStep.description && (
<div className="text-muted-foreground text-xs">
{currentStep.description}
</div>
)}
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium">Step Progress</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Current</span>
<span>Step {currentStepIndex + 1}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span>{steps.length - currentStepIndex - 1} steps</span>
</div>
</div>
{currentStep.type === "robot_action" && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Robot is executing this step. Monitor progress in the
monitoring panel.
</AlertDescription>
</Alert>
)}
</div>
) : (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-xs">
{trial.status === "scheduled"
? "Start trial to see current step"
: trial.status === "in_progress"
? "No current step"
: "Trial has ended"}
</div>
)}
</div>
</ScrollArea>
</TabsContent>
{/* Quick Actions Tab */}
<TabsContent
value="actions"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="space-y-2 p-3">
{trial.status === "in_progress" ? (
<>
<div className="mb-2 text-xs font-medium">
Quick Actions
</div>
{currentStep?.type === "wizard_action" && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start"
onClick={() => { onClick={() => onExecuteAction("step_complete")}
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
disabled={readOnly} disabled={readOnly}
> >
<CheckCircle className="mr-2 h-3 w-3" /> <CheckCircle className="mr-2 h-3 w-3" />
Acknowledge Mark Step Complete
</Button> </Button>
)}
</div>
) : (
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
Controls available during trial
</div>
)}
</div>
<Button <Separator />
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
disabled={readOnly}
>
<AlertCircle className="mr-2 h-3 w-3" />
Intervene
</Button>
<Button {/* Robot Controls (Merged from System & Robot Tab) */}
variant="outline" <div className="space-y-3">
size="sm" <div className="flex items-center justify-between">
className="w-full justify-start" <span className="text-muted-foreground text-xs">Connection</span>
onClick={() => { {_isConnected ? (
console.log("[WizardControlPanel] Add Note clicked"); <Badge variant="default" className="bg-green-600 text-xs">Connected</Badge>
onExecuteAction("note", { content: "Wizard note" });
}}
disabled={readOnly}
>
<User className="mr-2 h-3 w-3" />
Add Note
</Button>
<Separator />
{currentStep?.type === "wizard_action" && (
<div className="space-y-2">
<div className="text-xs font-medium">Step Actions</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")}
disabled={readOnly}
>
<CheckCircle className="mr-2 h-3 w-3" />
Mark Complete
</Button>
</div>
)}
</>
) : ( ) : (
<div className="flex h-32 items-center justify-center"> <Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
<div className="text-muted-foreground text-center text-xs">
{trial.status === "scheduled"
? "Start trial to access actions"
: "Actions unavailable - trial not active"}
</div>
</div>
)} )}
</div> </div>
</ScrollArea>
</TabsContent>
{/* Robot Actions Tab */} <div className="flex items-center justify-between">
<TabsContent <Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
value="robot" <Switch
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col" id="autonomous-life"
> checked={!!autonomousLife}
<ScrollArea className="h-full"> onCheckedChange={handleAutonomousLifeChange}
<div className="p-3"> disabled={!_isConnected || readOnly}
{studyId && onExecuteRobotAction ? ( className="scale-75"
<div className={readOnly ? "pointer-events-none opacity-50" : ""}> />
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
</div>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Robot actions are not available. Study ID or action
handler is missing.
</AlertDescription>
</Alert>
)}
</div> </div>
</ScrollArea>
</TabsContent> <Separator />
</div>
</Tabs> {/* Robot Actions Panel Integration */}
</div> {studyId && onExecuteRobotAction ? (
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
</div>
) : (
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
)}
</div>
</div>
</ScrollArea>
</div >
</div >
); );
} });

View File

@@ -3,16 +3,15 @@
import React from "react"; import React from "react";
import { import {
Play, Play,
Clock, SkipForward,
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
Bot,
User,
Activity,
Zap,
ArrowRight, ArrowRight,
AlertTriangle, Zap,
Loader2,
Clock,
RotateCcw, RotateCcw,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -26,8 +25,17 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions?: { actions?: {
id: string; id: string;
@@ -129,30 +137,31 @@ export function WizardExecutionPanel({
const activeActionIndex = completedActionsCount; const activeActionIndex = completedActionsCount;
// Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (activeActionRef.current) {
activeActionRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [activeActionIndex, currentStepIndex]);
// Pre-trial state // Pre-trial state
if (trial.status === "scheduled") { if (trial.status === "scheduled") {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="border-b p-3"> <div className="flex-1 flex items-center justify-center p-6">
<h3 className="text-sm font-medium">Trial Ready</h3> <div className="w-full max-w-md space-y-4 text-center">
<p className="text-muted-foreground text-xs"> <Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
{steps.length} steps prepared for execution
</p>
</div>
<div className="flex h-full flex-1 items-center justify-center p-6">
<div className="w-full max-w-md space-y-3 text-center">
<Clock className="text-muted-foreground mx-auto h-8 w-8" />
<div> <div>
<h4 className="text-sm font-medium">Ready to Begin</h4> <h4 className="text-lg font-medium">Ready to Begin</h4>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-sm">
Use the control panel to start this trial {steps.length} steps prepared. Use controls to start.
</p> </p>
</div> </div>
<div className="text-muted-foreground space-y-1 text-xs">
<div>Experiment: {trial.experiment.name}</div>
<div>Participant: {trial.participant.participantCode}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -197,160 +206,188 @@ export function WizardExecutionPanel({
// Active trial state // Active trial state
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col overflow-hidden">
{/* Header */} <div className="flex-1 min-h-0 relative">
<div className="border-b p-3"> <ScrollArea className="h-full w-full">
<div className="flex items-center justify-between"> <div className="pr-4">
<h3 className="text-sm font-medium">Trial Execution</h3> {currentStep ? (
<Badge variant="secondary" className="text-xs"> <div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
{currentStepIndex + 1} / {steps.length} {/* Header Info */}
</Badge> <div className="space-y-1 pb-4 border-b">
</div> <h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
{currentStep && ( {currentStep.description && (
<p className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground">{currentStep.description}</div>
{currentStep.name} )}
</p>
)}
</div>
{/* Simplified Content - Sequential Focus */}
<div className="relative flex-1 overflow-hidden">
<ScrollArea className="h-full">
{currentStep ? (
<div className="flex flex-col gap-6 p-6">
{/* Header Info (Simplified) */}
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
{currentStep.description && (
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
)}
</div>
</div> </div>
</div>
{/* Action Sequence */} {/* Action Sequence */}
{currentStep.actions && currentStep.actions.length > 0 && ( {currentStep.actions && currentStep.actions.length > 0 && (
<div className="space-y-4"> <div className="relative ml-3 space-y-0 pt-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Execution Sequence
</h3>
</div>
<div className="grid gap-3">
{currentStep.actions.map((action, idx) => { {currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex; const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex; const isActive = idx === activeActionIndex;
const isLast = idx === currentStep.actions!.length - 1;
return ( return (
<div <div
key={action.id} key={action.id}
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" : className="relative pl-8 pb-10 last:pb-0"
isCompleted ? "bg-muted/30 border-transparent opacity-70" : ref={isActive ? activeActionRef : undefined}
"bg-card border-border opacity-50"
}`}
> >
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" : {/* Connecting Line */}
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" : {!isLast && (
"bg-transparent text-muted-foreground border-transparent" <div
}`}> className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1} />
</div> )}
<div className="flex-1 min-w-0"> {/* Marker */}
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div> <div
{action.description && ( className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
<div className="text-xs text-muted-foreground line-clamp-1"> ? "border-primary bg-primary text-primary-foreground"
{action.description} : isActive
</div> ? "border-primary ring-4 ring-primary/10 scale-110"
: "border-muted-foreground/30 text-muted-foreground"
}`}
>
{isCompleted ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-bold">{idx + 1}</span>
)} )}
</div> </div>
{action.pluginId && isActive && ( {/* Content Card */}
<div className="flex items-center gap-2"> <div
<Button className={`rounded-lg border transition-all duration-300 ${isActive
size="sm" ? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
variant="ghost" : "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
className="h-9 px-3 text-muted-foreground hover:text-foreground" }`}
onClick={(e) => { >
e.preventDefault(); <div className="space-y-2">
e.stopPropagation(); <div className="flex items-start justify-between gap-4">
console.log("Skip clicked"); <div
// Fire and forget className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
onSkipAction( }`}
action.pluginId!, >
action.type.includes(".") {action.name}
? action.type.split(".").pop()! </div>
: action.type,
action.parameters || {},
{ autoAdvance: false }
);
onActionCompleted();
}}
disabled={readOnly}
>
Skip
</Button>
<Button
size="default"
className="h-10 px-4 shadow-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Execute clicked");
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false },
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-4 w-4" />
Execute
</Button>
</div>
)}
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
{!action.pluginId && isActive && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
Mark Done
</Button>
</div>
)}
{/* Completed State Indicator */}
{isCompleted && (
<div className="flex items-center gap-2 px-3">
<div className="text-xs font-medium text-green-600">
Done
</div> </div>
{action.pluginId && (
<> {action.description && (
<div className="text-sm text-muted-foreground">
{action.description}
</div>
)}
{/* Active Action Controls */}
{isActive && (
<div className="pt-3 flex items-center gap-3">
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
<>
<Button
size="sm"
className="shadow-sm min-w-[100px]"
onClick={(e) => {
e.preventDefault();
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false }
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-3.5 w-3.5" />
Execute
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.preventDefault();
onSkipAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false }
);
onActionCompleted();
}}
disabled={readOnly}
>
Skip
</Button>
</>
) : (
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
Mark Done
</Button>
)}
</div>
)}
{/* Wizard Wait For Response / Branching UI */}
{isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && (
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{(action.parameters.options as any[]).map((opt, optIdx) => {
// Handle both string options and object options
const label = typeof opt === 'string' ? opt : opt.label;
const value = typeof opt === 'string' ? opt : opt.value;
const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined;
return (
<Button
key={optIdx}
variant="outline"
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
onClick={(e) => {
e.preventDefault();
onExecuteAction(
action.id,
{
value,
label,
nextStepId
}
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">{String(label)}</span>
{typeof opt !== 'string' && value && <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">{String(value)}</span>}
</div>
</Button>
);
})}
</div>
)}
{/* Completed State Actions */}
{isCompleted && action.pluginId && (
<div className="pt-1 flex items-center gap-1">
<Button <Button
size="icon" size="sm"
variant="ghost" variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
title="Retry Action"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
// Execute again without advancing count
onExecuteRobotAction( onExecuteRobotAction(
action.pluginId!, action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.type.includes(".") ? action.type.split(".").pop()! : action.type,
@@ -360,81 +397,47 @@ export function WizardExecutionPanel({
}} }}
disabled={readOnly || isExecuting} disabled={readOnly || isExecuting}
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button> </Button>
<Button </div>
size="icon"
variant="ghost"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
title="Mark Issue"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExecuteAction("note", {
content: `Reported issue with action: ${action.name}`,
category: "system_issue"
});
}}
disabled={readOnly}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
</>
)} )}
</div> </div>
)} </div>
</div> </div>
) );
})} })}
</div> </div>
)
}
{/* Manual Advance Button */} {/* Manual Advance Button */}
{activeActionIndex >= (currentStep.actions?.length || 0) && ( {activeActionIndex >= (currentStep.actions?.length || 0) && (
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-center pb-8">
<Button
size="lg"
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
disabled={readOnly || isExecuting}
>
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</div>
)}
</div>
)}
{/* Manual Wizard Controls (If applicable) */}
{currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-1 gap-3">
<Button <Button
variant="outline" size="lg"
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800" onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
onClick={() => onExecuteAction("intervene")} className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
disabled={readOnly} ? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
disabled={readOnly || isExecuting}
> >
<Zap className="mr-2 h-4 w-4" /> {currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
Flag Issue / Intervention <ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
</div> </div>
</div> )}
)} </div>
</div> ) : (
) : ( <div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
<div className="flex h-full items-center justify-center text-muted-foreground"> <Loader2 className="h-8 w-8 animate-spin opacity-50" />
No active step <div className="text-sm">Waiting for trial to start...</div>
</div> </div>
)} )}
</div>
</ScrollArea> </ScrollArea>
{/* Scroll Hint Fade */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
</div> </div>
</div > </div>
); );
} }

View File

@@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
return ( return (
<div className="flex h-full flex-col gap-2 p-2"> <div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */} {/* Camera View - Always Visible */}
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group"> <div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<WebcamPanel readOnly={readOnly} /> <WebcamPanel readOnly={readOnly} />
</div> </div>
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
{rosConnected ? ( {rosConnected ? (
<Power className="h-3 w-3 text-green-600" /> <Power className="h-3 w-3 text-green-600" />
) : ( ) : (
<PowerOff className="h-3 w-3 text-gray-400" /> <Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
)} )}
</div> </div>
</div> </div>

View File

@@ -31,6 +31,7 @@ interface WizardObservationPaneProps {
) => Promise<void>; ) => Promise<void>;
isSubmitting?: boolean; isSubmitting?: boolean;
readOnly?: boolean; readOnly?: boolean;
activeTab?: "notes" | "timeline";
} }
export function WizardObservationPane({ export function WizardObservationPane({
@@ -38,6 +39,7 @@ export function WizardObservationPane({
isSubmitting = false, isSubmitting = false,
trialEvents = [], trialEvents = [],
readOnly = false, readOnly = false,
activeTab = "notes",
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) { }: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [category, setCategory] = useState("observation"); const [category, setCategory] = useState("observation");
@@ -68,95 +70,82 @@ export function WizardObservationPane({
}; };
return ( return (
<div className="flex h-full flex-col border-t bg-background"> <div className="flex h-full flex-col bg-background">
<Tabs defaultValue="notes" className="flex h-full flex-col"> <div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
<div className="border-b px-4 bg-muted/30"> <div className="flex flex-1 flex-col gap-2">
<TabsList className="h-9 -mb-px bg-transparent p-0"> <Textarea
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none"> placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
Notes & Observations className="flex-1 resize-none font-mono text-sm"
</TabsTrigger> value={note}
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none"> onChange={(e) => setNote(e.target.value)}
Timeline onKeyDown={handleKeyDown}
</TabsTrigger> disabled={readOnly}
</TabsList> />
</div>
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden"> <div className="flex items-center gap-2">
<div className="flex flex-1 flex-col gap-2"> <Select value={category} onValueChange={setCategory} disabled={readOnly}>
<Textarea <SelectTrigger className="w-[140px] h-8 text-xs">
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."} <SelectValue placeholder="Category" />
className="flex-1 resize-none font-mono text-sm" </SelectTrigger>
value={note} <SelectContent>
onChange={(e) => setNote(e.target.value)} <SelectItem value="observation">Observation</SelectItem>
onKeyDown={handleKeyDown} <SelectItem value="participant_behavior">Behavior</SelectItem>
disabled={readOnly} <SelectItem value="system_issue">System Issue</SelectItem>
/> <SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2"> <div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
<Select value={category} onValueChange={setCategory} disabled={readOnly}> <Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
<SelectTrigger className="w-[140px] h-8 text-xs"> <input
<SelectValue placeholder="Category" /> type="text"
</SelectTrigger> placeholder={readOnly ? "" : "Add tags..."}
<SelectContent> className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
<SelectItem value="observation">Observation</SelectItem> value={currentTag}
<SelectItem value="participant_behavior">Behavior</SelectItem> onChange={(e) => setCurrentTag(e.target.value)}
<SelectItem value="system_issue">System Issue</SelectItem> onKeyDown={(e) => {
<SelectItem value="success">Success</SelectItem> if (e.key === "Enter") {
<SelectItem value="failure">Failure</SelectItem> e.preventDefault();
</SelectContent> addTag();
</Select> }
}}
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8"> onBlur={addTag}
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} /> disabled={readOnly}
<input />
type="text"
placeholder={readOnly ? "" : "Add tags..."}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
disabled={readOnly}
/>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
</div> </div>
{tags.length > 0 && ( <Button
<div className="flex flex-wrap gap-1"> size="sm"
{tags.map((tag) => ( onClick={handleSubmit}
<Badge disabled={isSubmitting || !note.trim() || readOnly}
key={tag} className="h-8"
variant="secondary" >
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive" <Send className="mr-2 h-3 w-3" />
onClick={() => setTags(tags.filter((t) => t !== tag))} Add Note
> </Button>
#{tag}
</Badge>
))}
</div>
)}
</div> </div>
</TabsContent>
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden"> {tags.length > 0 && (
<HorizontalTimeline events={trialEvents} /> <div className="flex flex-wrap gap-1">
</TabsContent> {tags.map((tag) => (
</Tabs> <Badge
key={tag}
variant="secondary"
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
onClick={() => setTags(tags.filter((t) => t !== tag))}
>
#{tag}
</Badge>
))}
</div>
)}
</div>
</div>
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
<HorizontalTimeline events={trialEvents} />
</div>
</div> </div>
); );
} }

View File

@@ -127,12 +127,14 @@ export function EntityForm<T extends FieldValues = FieldValues>({
<div <div
className={cn( className={cn(
"grid gap-8 w-full", "grid gap-8 w-full",
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width // If sidebar exists, use 2-column layout. If not, use full width (max-w-7xl centered).
layout === "full-width" && "grid-cols-1", sidebar && layout === "default"
? "grid-cols-1 lg:grid-cols-3"
: "grid-cols-1 max-w-7xl mx-auto",
)} )}
> >
{/* Main Form */} {/* Main Form */}
<div className={layout === "default" ? "lg:col-span-2" : "col-span-1"}> <div className={sidebar && layout === "default" ? "lg:col-span-2" : "col-span-1"}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>

View File

@@ -168,7 +168,6 @@ export function convertDatabaseToSteps(
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
return sortedSteps.map((dbStep, idx) => { return sortedSteps.map((dbStep, idx) => {
// console.log(`[block-converter] Step ${dbStep.name} OrderIndex:`, dbStep.orderIndex, dbStep.order_index);
return { return {
id: dbStep.id, id: dbStep.id,
name: dbStep.name, name: dbStep.name,

View File

@@ -24,7 +24,7 @@ export type TriggerType =
export interface ActionParameter { export interface ActionParameter {
id: string; id: string;
name: string; name: string;
type: "text" | "number" | "select" | "boolean"; type: "text" | "number" | "select" | "boolean" | "json" | "array";
placeholder?: string; placeholder?: string;
options?: string[]; options?: string[];
min?: number; min?: number;

View File

@@ -148,7 +148,11 @@ export class WizardRosService extends EventEmitter {
console.error("[WizardROS] WebSocket error:", error); console.error("[WizardROS] WebSocket error:", error);
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
this.isConnecting = false; this.isConnecting = false;
this.emit("error", error);
// Prevent unhandled error event if no listeners
if (this.listenerCount("error") > 0) {
this.emit("error", error);
}
reject(error); reject(error);
}; };
}); });

View File

@@ -0,0 +1,51 @@
{
"id": "hristudio-core",
"name": "HRIStudio Core",
"version": "1.0.0",
"description": "Essential platform control flow and logic actions.",
"author": "HRIStudio",
"trustLevel": "official",
"actionDefinitions": [
{
"id": "wait",
"name": "Wait",
"description": "Wait for specified time",
"category": "control",
"icon": "Clock",
"color": "#f59e0b",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"duration": {
"title": "Duration (seconds)",
"type": "number",
"minimum": 0.1,
"maximum": 300,
"default": 2,
"description": "Time to wait in seconds"
}
},
"required": [
"duration"
]
},
"timeout": 60000,
"retryable": false,
"nestable": false
},
{
"id": "branch",
"name": "Branch / Decision",
"description": "Prompt the wizard to choose a path.",
"category": "control",
"icon": "GitBranch",
"color": "#f97316",
"parameters": {},
"parameterSchema": {},
"timeout": 0,
"retryable": false,
"nestable": false
}
]
}

View File

@@ -0,0 +1,107 @@
{
"id": "hristudio-woz",
"name": "Wizard of Oz Features",
"version": "1.0.0",
"description": "Standard capabilities for Wizard of Oz studies.",
"author": "HRIStudio",
"trustLevel": "official",
"actionDefinitions": [
{
"id": "wizard_say",
"name": "Wizard Says",
"description": "Wizard speaks to participant",
"category": "wizard",
"icon": "MessageSquare",
"color": "#a855f7",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"message": {
"title": "Message",
"type": "string",
"description": "Text to display/speak"
},
"tone": {
"title": "Tone",
"type": "string",
"enum": [
"neutral",
"friendly",
"encouraging"
],
"default": "neutral"
}
},
"required": [
"message"
]
},
"timeout": 30000,
"retryable": true,
"nestable": false
},
{
"id": "wizard_wait_for_response",
"name": "Wait for Wizard Input",
"description": "Pause execution until wizard provides input",
"category": "wizard",
"icon": "HandMetal",
"color": "#a855f7",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"prompt_text": {
"title": "Prompt Text",
"type": "string",
"description": "What did the participant say?"
},
"options": {
"title": "Response Options",
"type": "array",
"items": {
"type": "string"
},
"description": "Choices for the Wizard"
}
},
"required": [
"prompt_text"
]
},
"timeout": 0,
"retryable": false,
"nestable": false
},
{
"id": "observe",
"name": "Observe",
"description": "Record participant behavior",
"category": "observation",
"icon": "Eye",
"color": "#8b5cf6",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"behavior": {
"title": "Behavior to observe",
"type": "string",
"enum": [
"facial_expression",
"body_language",
"verbal_response"
]
}
},
"required": [
"behavior"
]
},
"timeout": 120000,
"retryable": false,
"nestable": false
}
]
}

View File

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -87,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
// Check study access // Check study access
await checkStudyAccess(ctx.db, userId, studyId); await checkStudyAccess(ctx.db, userId, studyId);
const conditions = [eq(experiments.studyId, studyId)]; const conditions = [
eq(experiments.studyId, studyId),
isNull(experiments.deletedAt),
];
if (status) { if (status) {
conditions.push(eq(experiments.status, status)); conditions.push(eq(experiments.status, status));
} }
@@ -224,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
} }
// Build where conditions // Build where conditions
const conditions = [inArray(experiments.studyId, studyIds)]; const conditions = [
inArray(experiments.studyId, studyIds),
isNull(experiments.deletedAt),
];
if (status) { if (status) {
conditions.push(eq(experiments.status, status)); conditions.push(eq(experiments.status, status));
@@ -1543,7 +1549,8 @@ export const experimentsRouter = createTRPCRouter({
description: step.description, description: step.description,
order: step.orderIndex, order: step.orderIndex,
duration: step.durationEstimate, duration: step.durationEstimate,
parameters: step.conditions as Record<string, unknown>, parameters: {} as Record<string, unknown>, // No standard parameters on Step, only Conditions
conditions: step.conditions as Record<string, unknown>, // Correctly map conditions
parentId: undefined, // Not supported in current schema parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed children: [], // TODO: implement hierarchical steps if needed
actions: step.actions.map((action) => ({ actions: step.actions.map((action) => ({

View File

@@ -5,8 +5,9 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
activityLogs, consentForms, participantConsents, participants, studyMembers, trials activityLogs, consentForms, participantConsents, participants, studyMembers, trials
} from "~/server/db/schema"; } from "~/server/db/schema";
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
// Helper function to check study access // Helper function to check study access
async function checkStudyAccess( async function checkStudyAccess(
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
getConsentUploadUrl: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
participantId: z.string().uuid(),
filename: z.string(),
contentType: z.string(),
size: z.number().max(10 * 1024 * 1024), // 10MB limit
})
)
.mutation(async ({ ctx, input }) => {
const { studyId, participantId, filename, contentType, size } = input;
const userId = ctx.session.user.id;
// Check study access with researcher permission
await checkStudyAccess(ctx.db, userId, studyId, ["owner", "researcher", "wizard"]);
// Validate file type
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
const validation = validateFile(filename, size, allowedTypes);
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
// Generate key: studies/{studyId}/participants/{participantId}/consent/{timestamp}-{filename}
const key = `studies/${studyId}/participants/${participantId}/consent/${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
// Generate presigned URL
const url = await getUploadUrl(key, contentType);
return { url, key };
}),
recordConsent: protectedProcedure recordConsent: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
consentFormId: z.string().uuid(), consentFormId: z.string().uuid(),
signatureData: z.string().optional(), signatureData: z.string().optional(),
ipAddress: z.string().optional(), ipAddress: z.string().optional(),
storagePath: z.string().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { participantId, consentFormId, signatureData, ipAddress } = input; const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
// Get participant to check study access // Get participant to check study access
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
consentFormId, consentFormId,
signatureData, signatureData,
ipAddress, ipAddress,
storagePath,
}) })
.returning(); .returning();

View File

@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env"; import { env } from "~/env";
import { uploadFile } from "~/lib/storage/minio";
// Helper function to check if user has access to trial // Helper function to check if user has access to trial
async function checkTrialAccess( async function checkTrialAccess(
@@ -542,6 +543,14 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Log trial start event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_started",
timestamp: new Date(),
data: { userId },
});
return trial[0]; return trial[0];
}), }),
@@ -625,9 +634,136 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Log trial abort event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_aborted",
timestamp: new Date(),
data: { userId, reason: input.reason },
});
return trial[0]; return trial[0];
}), }),
pause: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// Log trial paused event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_paused",
timestamp: new Date(),
data: { userId },
});
return { success: true };
}),
archive: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
const trial = await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// 1. Fetch full trial data
const trialData = await db.query.trials.findFirst({
where: eq(trials.id, input.id),
with: {
experiment: true,
participant: true,
wizard: true,
},
});
if (!trialData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Trial data not found",
});
}
// 2. Fetch all events
const events = await db
.select()
.from(trialEvents)
.where(eq(trialEvents.trialId, input.id))
.orderBy(asc(trialEvents.timestamp));
// 3. Fetch all interventions
const interventions = await db
.select()
.from(wizardInterventions)
.where(eq(wizardInterventions.trialId, input.id))
.orderBy(asc(wizardInterventions.timestamp));
// 4. Construct Archive Object
const archiveObject = {
trial: trialData,
events,
interventions,
archivedAt: new Date().toISOString(),
archivedBy: userId,
};
// 5. Upload to MinIO
const filename = `archive-${input.id}-${Date.now()}.json`;
const key = `trials/${input.id}/${filename}`;
try {
const uploadResult = await uploadFile({
key,
body: JSON.stringify(archiveObject, null, 2),
contentType: "application/json",
});
// 6. Update Trial Metadata with Archive URL/Key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentMetadata = (trialData.metadata as any) || {};
await db
.update(trials)
.set({
metadata: {
...currentMetadata,
archiveKey: uploadResult.key,
archiveUrl: uploadResult.url,
archivedAt: new Date(),
},
})
.where(eq(trials.id, input.id));
return { success: true, url: uploadResult.url };
} catch (error) {
console.error("Failed to archive trial:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to upload archive to storage",
});
}
}),
logEvent: protectedProcedure logEvent: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -1046,6 +1182,19 @@ export const trialsRouter = createTRPCRouter({
createdBy: ctx.session.user.id, createdBy: ctx.session.user.id,
}); });
// Update execution variables if data provided
if (input.data) {
const executionEngine = getExecutionEngine();
Object.entries(input.data).forEach(([key, value]) => {
executionEngine.setVariable(input.trialId, key, value);
});
// Also set a generic "last_wizard_response" if response field exists
if ('response' in input.data) {
executionEngine.setVariable(input.trialId, "last_wizard_response", input.data.response);
}
}
return { success: true }; return { success: true };
}), }),

View File

@@ -47,6 +47,7 @@ export interface StepDefinition {
type: string; type: string;
orderIndex: number; orderIndex: number;
condition?: string; condition?: string;
conditions?: Record<string, any>;
actions: ActionDefinition[]; actions: ActionDefinition[];
} }
@@ -173,7 +174,8 @@ export class TrialExecutionEngine {
description: step.description || undefined, description: step.description || undefined,
type: step.type, type: step.type,
orderIndex: step.orderIndex, orderIndex: step.orderIndex,
condition: (step.conditions as string) || undefined, condition: typeof step.conditions === 'string' ? step.conditions : undefined,
conditions: typeof step.conditions === 'object' ? (step.conditions as Record<string, any>) : undefined,
actions: actionDefinitions, actions: actionDefinitions,
}); });
} }
@@ -399,20 +401,37 @@ export class TrialExecutionEngine {
switch (action.type) { switch (action.type) {
case "wait": case "wait":
case "hristudio-core.wait":
return await this.executeWaitAction(action); return await this.executeWaitAction(action);
case "branch":
case "hristudio-core.branch":
// Branch actions are logical markers; execution is just a pass-through
return {
success: true,
completed: true,
duration: 0,
data: { message: "Branch point reached" },
};
case "wizard_say": case "wizard_say":
case "hristudio-woz.wizard_say":
return await this.executeWizardAction(trialId, action);
case "wizard_wait_for_response":
case "hristudio-woz.wizard_wait_for_response":
return await this.executeWizardAction(trialId, action); return await this.executeWizardAction(trialId, action);
case "wizard_gesture": case "wizard_gesture":
return await this.executeWizardAction(trialId, action); return await this.executeWizardAction(trialId, action);
case "observe_behavior": case "observe_behavior":
case "hristudio-woz.observe":
return await this.executeObservationAction(trialId, action); return await this.executeObservationAction(trialId, action);
default: default:
// Check if it's a robot action (contains plugin prefix) // Check if it's a robot action (contains plugin prefix)
if (action.type.includes(".")) { if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
return await this.executeRobotAction(trialId, action); return await this.executeRobotAction(trialId, action);
} }
@@ -424,6 +443,7 @@ export class TrialExecutionEngine {
data: { data: {
message: `Action type '${action.type}' not implemented yet`, message: `Action type '${action.type}' not implemented yet`,
parameters: action.parameters, parameters: action.parameters,
localHandler: true // Indicate this fell through to default local handler
}, },
}; };
} }
@@ -813,6 +833,16 @@ export class TrialExecutionEngine {
} }
} }
/**
* Set a variable in the trial context
*/
setVariable(trialId: string, key: string, value: unknown): void {
const context = this.activeTrials.get(trialId);
if (context) {
context.variables[key] = value;
}
}
/** /**
* Advance to the next step * Advance to the next step
*/ */
@@ -827,12 +857,54 @@ export class TrialExecutionEngine {
return { success: false, error: "No steps loaded for trial" }; return { success: false, error: "No steps loaded for trial" };
} }
const currentStep = steps[context.currentStepIndex];
if (!currentStep) {
return { success: false, error: "Invalid current step" };
}
const previousStepIndex = context.currentStepIndex; const previousStepIndex = context.currentStepIndex;
context.currentStepIndex++; let nextStepIndex = context.currentStepIndex + 1;
// Check for branching conditions
if (currentStep.conditions && currentStep.conditions.options) {
const { variable, options } = currentStep.conditions as any;
// Default to "last_wizard_response" if variable not specified, for backward compatibility
const variableName = variable || "last_wizard_response";
const variableValue = context.variables[variableName];
console.log(`[TrialExecution] Checking branch condition for step ${currentStep.id}: variable=${variableName}, value=${variableValue}`);
if (variableValue !== undefined) {
// Find matching option
// option.value matches variableValue (e.g., label string)
const matchedOption = options.find((opt: any) => opt.value === variableValue || opt.label === variableValue);
if (matchedOption) {
if (matchedOption.nextStepId) {
// Find step by ID
const targetStepIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
if (targetStepIndex !== -1) {
nextStepIndex = targetStepIndex;
console.log(`[TrialExecution] Taking branch to step ID ${matchedOption.nextStepId} (Index ${nextStepIndex})`);
} else {
console.warn(`[TrialExecution] Branch target step ID ${matchedOption.nextStepId} not found`);
}
} else if (matchedOption.nextStepIndex !== undefined) {
// Fallback to relative/absolute index if ID not present (legacy)
nextStepIndex = matchedOption.nextStepIndex;
console.log(`[TrialExecution] Taking branch to index ${nextStepIndex}`);
}
}
}
}
context.currentStepIndex = nextStepIndex;
await this.logTrialEvent(trialId, "step_transition", { await this.logTrialEvent(trialId, "step_transition", {
fromStepIndex: previousStepIndex, fromStepIndex: previousStepIndex,
toStepIndex: context.currentStepIndex, toStepIndex: context.currentStepIndex,
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence"
}); });
// Check if we've completed all steps // Check if we've completed all steps