mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Compare commits
3 Commits
388897c70e
...
85b951f742
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b951f742 | |||
| a8c868ad3f | |||
| 0f535f6887 |
58
scripts/debug-experiment-structure.ts
Normal file
58
scripts/debug-experiment-structure.ts
Normal 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);
|
||||||
|
});
|
||||||
42
scripts/inspect-all-steps.ts
Normal file
42
scripts/inspect-all-steps.ts
Normal 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);
|
||||||
|
});
|
||||||
47
scripts/inspect-branch-action.ts
Normal file
47
scripts/inspect-branch-action.ts
Normal 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);
|
||||||
|
});
|
||||||
30
scripts/inspect-branch-steps.ts
Normal file
30
scripts/inspect-branch-steps.ts
Normal 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
24
scripts/inspect-db.ts
Normal 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
61
scripts/inspect-step.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
33
scripts/inspect-visual-design.ts
Normal file
33
scripts/inspect-visual-design.ts
Normal 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);
|
||||||
|
});
|
||||||
69
scripts/patch-branch-action-params.ts
Normal file
69
scripts/patch-branch-action-params.ts
Normal 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);
|
||||||
|
});
|
||||||
92
scripts/patch-branch-steps.ts
Normal file
92
scripts/patch-branch-steps.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
78
scripts/simulate-branch-logic.ts
Normal file
78
scripts/simulate-branch-logic.ts
Normal 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");
|
||||||
44
scripts/verify-conversion.ts
Normal file
44
scripts/verify-conversion.ts
Normal 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);
|
||||||
|
});
|
||||||
74
scripts/verify-trpc-logic.ts
Normal file
74
scripts/verify-trpc-logic.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
319
src/components/analytics/study-analytics-data-table.tsx
Normal file
319
src/components/analytics/study-analytics-data-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---------------- */
|
||||||
|
|||||||
@@ -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?.({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
190
src/components/participants/ConsentUploadForm.tsx
Normal file
190
src/components/participants/ConsentUploadForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/participants/ParticipantConsentManager.tsx
Normal file
161
src/components/participants/ParticipantConsentManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
107
src/components/trials/analysis/events-columns.tsx
Normal file
107
src/components/trials/analysis/events-columns.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
101
src/components/trials/analysis/events-data-table.tsx
Normal file
101
src/components/trials/analysis/events-data-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 >
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal 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;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 >
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
51
src/plugins/definitions/hristudio-core.json
Normal file
51
src/plugins/definitions/hristudio-core.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
107
src/plugins/definitions/hristudio-woz.json
Normal file
107
src/plugins/definitions/hristudio-woz.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user