mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-15 08:29:52 -04:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b147f81322 | |||
| 880980dccd | |||
| 356084a3f1 | |||
| 14182bf078 | |||
| 943c7bd963 | |||
| 6b54724171 | |||
| 86c1f35537 | |||
| 5b5490cb90 | |||
| 6b98cad53e | |||
| 3e2aa894a0 | |||
| 27f633fb4b | |||
| 6243b62d3b | |||
| f16dd4aa22 | |||
| 7483e4a72b | |||
| 426b5e761b | |||
| cf21a27995 | |||
| 74b5507769 | |||
| 5c67fc106e | |||
| 4b04f2c415 | |||
| c959e61f95 | |||
| de1b125b13 | |||
| 143cf2ce50 | |||
| 61c7cc1e94 | |||
| 8f330cf5f0 | |||
| 254805008e | |||
| c923c63099 | |||
| c05384d1a0 | |||
| c0e5a4ffb8 | |||
| 51aaaa5208 | |||
| e402c51483 | |||
| 7c360dc860 | |||
| 1c7f0297a6 | |||
| 3959cf23f7 |
+10
-2
@@ -16,11 +16,19 @@
|
|||||||
AUTH_SECRET=""
|
AUTH_SECRET=""
|
||||||
|
|
||||||
# Drizzle
|
# Drizzle
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio"
|
DATABASE_URL="postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@localhost:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-hristudio}"
|
||||||
|
|
||||||
|
# PostgreSQL (used by docker-compose)
|
||||||
|
POSTGRES_USER="postgres"
|
||||||
|
POSTGRES_PASSWORD="postgres"
|
||||||
|
POSTGRES_DB="hristudio"
|
||||||
|
POSTGRES_PORT="5432"
|
||||||
|
|
||||||
# MinIO/S3 Configuration
|
# MinIO/S3 Configuration
|
||||||
MINIO_ENDPOINT="http://localhost:9000"
|
MINIO_ENDPOINT="http://localhost:${MINIO_PORT_API:-9000}"
|
||||||
MINIO_REGION="us-east-1"
|
MINIO_REGION="us-east-1"
|
||||||
MINIO_ACCESS_KEY="minioadmin"
|
MINIO_ACCESS_KEY="minioadmin"
|
||||||
MINIO_SECRET_KEY="minioadmin"
|
MINIO_SECRET_KEY="minioadmin"
|
||||||
MINIO_BUCKET_NAME="hristudio-data"
|
MINIO_BUCKET_NAME="hristudio-data"
|
||||||
|
MINIO_PORT_API="9000"
|
||||||
|
MINIO_PORT_CONSOLE="9001"
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ src/
|
|||||||
|
|
||||||
Comprehensive documentation available in the `docs/` folder:
|
Comprehensive documentation available in the `docs/` folder:
|
||||||
|
|
||||||
|
- **[Tutorials](docs/tutorials/README.md)**: Step-by-step guides for new users
|
||||||
- **[Quick Reference](docs/quick-reference.md)**: Essential commands and setup
|
- **[Quick Reference](docs/quick-reference.md)**: Essential commands and setup
|
||||||
- **[Implementation Guide](docs/implementation-guide.md)**: Technical implementation details
|
- **[Implementation Guide](docs/implementation-guide.md)**: Technical implementation details
|
||||||
- **[Project Status](docs/project-status.md)**: Current development state
|
- **[Project Status](docs/project-status.md)**: Current development state
|
||||||
|
|||||||
@@ -56,12 +56,12 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.45.2",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "16.2.1",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
@@ -438,25 +438,25 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="],
|
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||||
|
|
||||||
@@ -1088,7 +1088,7 @@
|
|||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
||||||
|
|
||||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||||
|
|
||||||
@@ -1174,7 +1174,7 @@
|
|||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="],
|
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
@@ -1562,7 +1562,7 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="],
|
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||||
|
|
||||||
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
|
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
|
||||||
|
|
||||||
@@ -2098,8 +2098,6 @@
|
|||||||
|
|
||||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
|
||||||
|
|
||||||
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|||||||
+11
-11
@@ -2,13 +2,13 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: hristudio
|
POSTGRES_DB: ${POSTGRES_DB:-hristudio}
|
||||||
PGSSLMODE: disable
|
PGSSLMODE: disable
|
||||||
command: -c ssl=off
|
command: -c ssl=off
|
||||||
ports:
|
ports:
|
||||||
- "5140:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -20,11 +20,11 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # API
|
- "${MINIO_PORT_API:-9000}:9000" # API
|
||||||
- "9001:9001" # Console
|
- "${MINIO_PORT_CONSOLE:-9001}:9001" # Console
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
@@ -35,9 +35,9 @@ services:
|
|||||||
- minio
|
- minio
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
/usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ACCESS_KEY:-minioadmin} ${MINIO_SECRET_KEY:-minioadmin};
|
||||||
/usr/bin/mc mb myminio/hristudio-data;
|
/usr/bin/mc mb myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||||
/usr/bin/mc anonymous set public myminio/hristudio-data;
|
/usr/bin/mc anonymous set public myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||||
exit 0;
|
exit 0;
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,28 @@ HRIStudio is a web-based Wizard-of-Oz platform for Human-Robot Interaction resea
|
|||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
|
| **[Tutorials](tutorials/README.md)** | Step-by-step guides for using HRIStudio |
|
||||||
| **[Quick Reference](quick-reference.md)** | Essential commands, setup, troubleshooting |
|
| **[Quick Reference](quick-reference.md)** | Essential commands, setup, troubleshooting |
|
||||||
| **[Project Status](project-status.md)** | Current development state (March 2026) |
|
| **[Project Status](project-status.md)** | Current development state (March 2026) |
|
||||||
| **[Implementation Guide](implementation-guide.md)** | Full technical implementation |
|
| **[Implementation Guide](implementation-guide.md)** | Full technical implementation |
|
||||||
| **[NAO6 Integration](nao6-quick-reference.md)** | Robot setup and commands |
|
| **[NAO6 Integration](nao6-quick-reference.md)** | Robot setup and commands |
|
||||||
|
|
||||||
|
## Tutorials
|
||||||
|
|
||||||
|
New to HRIStudio? Start with our comprehensive tutorials:
|
||||||
|
|
||||||
|
| Tutorial | Description | Time |
|
||||||
|
|----------|-------------|------|
|
||||||
|
| [Getting Started](tutorials/01-getting-started.md) | Installation and first login | 10 min |
|
||||||
|
| [Your First Study](tutorials/02-your-first-study.md) | Creating a research study | 15 min |
|
||||||
|
| [Designing Experiments](tutorials/03-designing-experiments.md) | Building experiment protocols | 25 min |
|
||||||
|
| [Running Trials](tutorials/04-running-trials.md) | Executing experiments | 20 min |
|
||||||
|
| [Wizard Interface](tutorials/05-wizard-interface.md) | Real-time trial control | 15 min |
|
||||||
|
| [Robot Integration](tutorials/06-robot-integration.md) | Connecting NAO6 robot | 20 min |
|
||||||
|
| [Forms & Surveys](tutorials/07-forms-and-surveys.md) | Managing consent and data | 15 min |
|
||||||
|
| [Data & Analysis](tutorials/08-data-and-analysis.md) | Collecting and exporting data | 15 min |
|
||||||
|
| [Simulation Mode](tutorials/09-simulation-mode.md) | Testing without a robot | 10 min |
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### 1. Clone & Install
|
### 1. Clone & Install
|
||||||
@@ -162,9 +179,22 @@ bun db:seed
|
|||||||
- `docs/quick-reference.md` - Commands & setup
|
- `docs/quick-reference.md` - Commands & setup
|
||||||
- `docs/nao6-quick-reference.md` - NAO6 commands
|
- `docs/nao6-quick-reference.md` - NAO6 commands
|
||||||
|
|
||||||
|
### Tutorials
|
||||||
|
- `docs/tutorials/README.md` - Tutorial overview
|
||||||
|
- `docs/tutorials/01-getting-started.md` - Installation & setup
|
||||||
|
- `docs/tutorials/02-your-first-study.md` - Creating studies
|
||||||
|
- `docs/tutorials/03-designing-experiments.md` - Building protocols
|
||||||
|
- `docs/tutorials/04-running-trials.md` - Executing trials
|
||||||
|
- `docs/tutorials/05-wizard-interface.md` - Trial control
|
||||||
|
- `docs/tutorials/06-robot-integration.md` - Robot setup
|
||||||
|
- `docs/tutorials/07-forms-and-surveys.md` - Forms management
|
||||||
|
- `docs/tutorials/08-data-and-analysis.md` - Data collection
|
||||||
|
- `docs/tutorials/09-simulation-mode.md` - Testing without robot
|
||||||
|
|
||||||
### Technical Documentation
|
### Technical Documentation
|
||||||
- `docs/implementation-guide.md` - Full technical implementation
|
- `docs/implementation-guide.md` - Full technical implementation
|
||||||
- `docs/project-status.md` - Development status
|
- `docs/project-status.md` - Development status
|
||||||
|
- `docs/mock-robot-simulation.md` - Robot simulation
|
||||||
|
|
||||||
### Archive (Historical)
|
### Archive (Historical)
|
||||||
- `docs/_archive/` - Old documentation (outdated but preserved)
|
- `docs/_archive/` - Old documentation (outdated but preserved)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# HRIStudio Mock Robot Simulation
|
||||||
|
|
||||||
|
This directory contains a mock robot server for simulating NAO6 robot connections without a physical robot.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Standalone Mock Server (Recommended for testing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts/mock-robot
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the mock robot WebSocket server on `ws://localhost:9090`.
|
||||||
|
|
||||||
|
### Option 2: Docker Compose Mock Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nao6-hristudio-integration
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.mock.yml --profile mock up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Client-Side Simulation (No server needed)
|
||||||
|
|
||||||
|
Enable simulation mode in the wizard interface:
|
||||||
|
- Set `NEXT_PUBLIC_SIMULATION_MODE=true` in your `.env` file
|
||||||
|
- Or use the simulation toggle in the UI
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ HRIStudio Platform │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Wizard Interface (Browser) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ wizard-ros-service.ts │ │
|
||||||
|
│ │ ├── simulationMode: true → Simulates locally │ │
|
||||||
|
│ │ └── simulationMode: false → Connects to server │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────┴────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ Real Mode Simulation Mode │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Mock Robot │ │ Local JS │ │
|
||||||
|
│ │ WebSocket │ │ Simulation │ │
|
||||||
|
│ │ Server │ │ (No server) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mock Robot Server Protocol
|
||||||
|
|
||||||
|
The mock server implements the rosbridge WebSocket protocol:
|
||||||
|
|
||||||
|
### Supported Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `subscribe` | Subscribe to a topic |
|
||||||
|
| `unsubscribe` | Unsubscribe from a topic |
|
||||||
|
| `publish` | Publish a message to a topic |
|
||||||
|
| `call_service` | Call a ROS service |
|
||||||
|
| `advertise` | Advertise a topic |
|
||||||
|
| `unadvertise` | Stop advertising a topic |
|
||||||
|
|
||||||
|
### Simulated Topics
|
||||||
|
|
||||||
|
| Topic | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/joint_states` | `sensor_msgs/JointState` | Joint positions (26 joints) |
|
||||||
|
| `/naoqi_driver/battery` | `naoqi_bridge_msgs/Battery` | Battery status (85%) |
|
||||||
|
| `/bumper` | `naoqi_bridge_msgs/Bumper` | Bumper contact sensors |
|
||||||
|
| `/hand_touch` | `naoqi_bridge_msgs/HandTouch` | Hand touch sensors |
|
||||||
|
| `/head_touch` | `naoqi_bridge_msgs/HeadTouch` | Head touch sensors |
|
||||||
|
| `/sonar/left` | `sensor_msgs/Range` | Left sonar distance |
|
||||||
|
| `/sonar/right` | `sensor_msgs/Range` | Right sonar distance |
|
||||||
|
|
||||||
|
### Simulated Services
|
||||||
|
|
||||||
|
| Service | Response |
|
||||||
|
|---------|----------|
|
||||||
|
| `/naoqi_driver/get_robot_info` | `{ robotName: "MOCK-NAO6", robotVersion: "6.0" }` |
|
||||||
|
| `/naoqi_driver/get_joint_names` | List of 26 joint names |
|
||||||
|
| `/naoqi_driver/get_position` | Current position `{ x, y, theta }` |
|
||||||
|
| `/naoqi_driver/is_waking_up` | `{ is_waking_up: false }` |
|
||||||
|
|
||||||
|
### Supported Actions
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `say_text` | `text` | Speak text |
|
||||||
|
| `walk_forward` | `speed` | Walk forward |
|
||||||
|
| `walk_backward` | `speed` | Walk backward |
|
||||||
|
| `turn_left` | `speed` | Turn left |
|
||||||
|
| `turn_right` | `speed` | Turn right |
|
||||||
|
| `stop` | - | Stop all movement |
|
||||||
|
| `move_head` | `yaw`, `pitch`, `speed` | Move head |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mock Robot Server (scripts/mock-robot)
|
||||||
|
MOCK_ROBOT_PORT=9090 # WebSocket port
|
||||||
|
MOCK_PUBLISH_INTERVAL=100 # Sensor update interval (ms)
|
||||||
|
|
||||||
|
# HRIStudio Client
|
||||||
|
NEXT_PUBLIC_SIMULATION_MODE=true # Enable client-side simulation
|
||||||
|
NEXT_PUBLIC_ROS_BRIDGE_URL=ws://localhost:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Start Mock Server
|
||||||
|
```bash
|
||||||
|
cd scripts/mock-robot
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start HRIStudio
|
||||||
|
```bash
|
||||||
|
cd hristudio
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Connection
|
||||||
|
Visit `http://localhost:3000/nao-test` and click "Connect". You should see:
|
||||||
|
- Connection status: `connected`
|
||||||
|
- Battery: ~85%
|
||||||
|
- Joint states updating
|
||||||
|
- Log messages showing subscriptions
|
||||||
|
|
||||||
|
### 4. Test Actions
|
||||||
|
Use the wizard interface to test:
|
||||||
|
- Speech actions
|
||||||
|
- Movement actions
|
||||||
|
- Head control
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Connection timeout" error
|
||||||
|
- Ensure mock server is running: `curl ws://localhost:9090`
|
||||||
|
- Check port is correct (default 9090)
|
||||||
|
|
||||||
|
### "Not connected to ROS bridge" error
|
||||||
|
- Enable simulation mode: `NEXT_PUBLIC_SIMULATION_MODE=true`
|
||||||
|
- Or connect to mock server first
|
||||||
|
|
||||||
|
### Actions not executing
|
||||||
|
- Check connection status in wizard interface
|
||||||
|
- Enable simulation mode if using client-side simulation
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `scripts/mock-robot/src/server.ts` | TypeScript mock server |
|
||||||
|
| `scripts/mock-robot/server.js` | JavaScript mock server (for Docker) |
|
||||||
|
| `src/lib/ros/wizard-ros-service.ts` | Client with simulation mode |
|
||||||
|
| `src/hooks/useWizardRos.ts` | React hook with simulation support |
|
||||||
|
| `docker-compose.mock.yml` | Docker mock service |
|
||||||
|
| `robot-plugins/plugins/nao6-mock.json` | Mock NAO6 plugin |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Simulated Actions
|
||||||
|
|
||||||
|
1. Edit `scripts/mock-robot/src/server.ts`
|
||||||
|
2. Add handler in `handlePublish()` or `handleServiceCall()`
|
||||||
|
3. Update `nao6-mock.json` plugin with new action definition
|
||||||
|
|
||||||
|
### Adding New Simulated Sensors
|
||||||
|
|
||||||
|
1. Edit `scripts/mock-robot/src/server.ts`
|
||||||
|
2. Add topic publishing in `publishRobotState()`
|
||||||
|
3. Update subscriber topics in `WizardRosService.subscribeToRobotTopics()`
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Tutorial 1: Getting Started
|
||||||
|
|
||||||
|
Learn how to set up HRIStudio and log in for the first time.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Install HRIStudio dependencies
|
||||||
|
- Start the development environment
|
||||||
|
- Log in and explore the interface
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) installed
|
||||||
|
- [Docker](https://docker.com) installed
|
||||||
|
- [Git](https://git-scm.com) installed
|
||||||
|
|
||||||
|
## Step 1: Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/soconnor0919/hristudio.git
|
||||||
|
cd hristudio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Install Dependencies
|
||||||
|
|
||||||
|
HRIStudio uses Bun as its package manager:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Start the Database
|
||||||
|
|
||||||
|
HRIStudio requires PostgreSQL. The easiest way is using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL and MinIO (for file storage)
|
||||||
|
bun run docker:up
|
||||||
|
|
||||||
|
# Push database schema
|
||||||
|
bun db:push
|
||||||
|
|
||||||
|
# Seed with sample data
|
||||||
|
bun db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the database schema and populates it with:
|
||||||
|
- 4 default user accounts
|
||||||
|
- Sample study and experiments
|
||||||
|
- Test participants and trials
|
||||||
|
|
||||||
|
## Step 4: Start the Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Step 5: Log In
|
||||||
|
|
||||||
|
Use one of the default accounts:
|
||||||
|
|
||||||
|
| Role | Email | Password |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Administrator | `sean@soconnor.dev` | `password123` |
|
||||||
|
| Researcher | `felipe.perrone@bucknell.edu` | `password123` |
|
||||||
|
| Wizard | `emily.watson@lab.edu` | `password123` |
|
||||||
|
| Observer | `maria.santos@tech.edu` | `password123` |
|
||||||
|
|
||||||
|
## Exploring the Interface
|
||||||
|
|
||||||
|
After logging in, you'll see the main dashboard:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ HRIStudio [User] [Settings] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ Studies │ │ Trials │ │Plugins │ │ Admin │ │
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Recent Activity │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • Study: Comparative WoZ Study - Ready │ │
|
||||||
|
│ │ • Trial: P101 - Completed (5 min ago) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- **Studies** - View and manage your research studies
|
||||||
|
- **Trials** - Monitor and manage experiment trials
|
||||||
|
- **Plugins** - Manage robot integrations
|
||||||
|
- **Admin** - System administration (admins only)
|
||||||
|
|
||||||
|
## Using Simulation Mode
|
||||||
|
|
||||||
|
If you don't have a physical robot, enable simulation mode:
|
||||||
|
|
||||||
|
1. Create or edit `hristudio/.env.local`
|
||||||
|
2. Add: `NEXT_PUBLIC_SIMULATION_MODE=true`
|
||||||
|
3. Restart the dev server
|
||||||
|
|
||||||
|
Simulation mode allows you to test experiments without connecting to a real robot.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if Docker is running
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Restart the database
|
||||||
|
bun run docker:down
|
||||||
|
bun run docker:up
|
||||||
|
bun db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If port 3000 is in use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use a different port
|
||||||
|
PORT=3001 bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seed Script Fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset the database
|
||||||
|
bun run docker:down -v
|
||||||
|
bun run docker:up
|
||||||
|
bun db:push
|
||||||
|
bun db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you're set up:
|
||||||
|
|
||||||
|
1. **[Your First Study](02-your-first-study.md)** - Create a research study
|
||||||
|
2. **[Designing Experiments](03-designing-experiments.md)** - Build your first protocol
|
||||||
|
3. **[Simulation Mode](09-simulation-mode.md)** - Test without a robot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Tutorials Overview](../tutorials/README.md) | **Next**: [Your First Study](02-your-first-study.md)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Tutorial 2: Your First Study
|
||||||
|
|
||||||
|
Learn how to create a research study and configure team access.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Create a new research study
|
||||||
|
- Configure study settings (IRB, institution)
|
||||||
|
- Add team members with appropriate roles
|
||||||
|
|
||||||
|
## What is a Study?
|
||||||
|
|
||||||
|
In HRIStudio, a **Study** is the top-level container for your research:
|
||||||
|
|
||||||
|
```
|
||||||
|
Study
|
||||||
|
├── Experiments (multiple protocols)
|
||||||
|
├── Participants (study participants)
|
||||||
|
├── Team Members (collaborators)
|
||||||
|
├── Forms & Surveys (consent, questionnaires)
|
||||||
|
└── Trials (individual experiment runs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Create a New Study
|
||||||
|
|
||||||
|
1. Log in as **Researcher** or **Administrator**
|
||||||
|
2. Click **Studies** in the sidebar
|
||||||
|
3. Click **Create Study**
|
||||||
|
|
||||||
|
### Study Settings
|
||||||
|
|
||||||
|
| Field | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| Name | Study title | Yes |
|
||||||
|
| Description | Brief overview of research goals | Yes |
|
||||||
|
| Institution | University or organization | No |
|
||||||
|
| IRB Protocol | Protocol number (e.g., 2024-HRI-001) | No |
|
||||||
|
| Status | Draft, Active, Completed, Archived | Yes |
|
||||||
|
|
||||||
|
### Example: Creating "Robot Trust Study"
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: Robot Trust Study
|
||||||
|
Description: Investigating how robot appearance affects human trust in collaborative tasks.
|
||||||
|
Institution: Bucknell University
|
||||||
|
IRB Protocol: 2024-HRI-TRUST
|
||||||
|
Status: Draft
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Add Team Members
|
||||||
|
|
||||||
|
Studies can have multiple collaborators with different roles:
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|-------------|
|
||||||
|
| Owner | Full access, can delete study |
|
||||||
|
| Researcher | Create/edit experiments, manage participants |
|
||||||
|
| Wizard | Execute trials, control robot |
|
||||||
|
| Observer | View-only access, add annotations |
|
||||||
|
|
||||||
|
### Adding a Wizard
|
||||||
|
|
||||||
|
1. Open your study
|
||||||
|
2. Go to **Team** tab
|
||||||
|
3. Click **Add Member**
|
||||||
|
4. Enter the wizard's email
|
||||||
|
5. Select **Wizard** role
|
||||||
|
6. Click **Invite**
|
||||||
|
|
||||||
|
The wizard will receive access to:
|
||||||
|
- View the study and experiments
|
||||||
|
- Execute trials
|
||||||
|
- Control the robot during trials
|
||||||
|
- Add notes to trials
|
||||||
|
|
||||||
|
## Step 3: Install Robot Plugins
|
||||||
|
|
||||||
|
For studies involving robots, you need to install the appropriate plugin:
|
||||||
|
|
||||||
|
1. Go to **Plugins** in the sidebar
|
||||||
|
2. Select your study from the dropdown
|
||||||
|
3. Click **Browse Plugins**
|
||||||
|
4. Find your robot (e.g., "NAO6 Robot (ROS2 Integration)")
|
||||||
|
5. Click **Install**
|
||||||
|
6. Configure robot settings (IP address, etc.)
|
||||||
|
|
||||||
|
### Plugin Configuration
|
||||||
|
|
||||||
|
For NAO6 robots:
|
||||||
|
|
||||||
|
```
|
||||||
|
Robot IP: 192.168.1.100
|
||||||
|
Connection Type: ROS2 Bridge
|
||||||
|
WebSocket URL: ws://localhost:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Create Forms
|
||||||
|
|
||||||
|
Before running trials, you need consent forms:
|
||||||
|
|
||||||
|
1. Go to **Forms** tab in your study
|
||||||
|
2. Click **Create Form**
|
||||||
|
3. Select form type:
|
||||||
|
- **Consent** - Informed consent documents
|
||||||
|
- **Survey** - Post-session questionnaires
|
||||||
|
- **Questionnaire** - Demographic forms
|
||||||
|
|
||||||
|
### Form Templates
|
||||||
|
|
||||||
|
HRIStudio provides templates to get started:
|
||||||
|
|
||||||
|
| Template | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| Informed Consent | Required for all participants |
|
||||||
|
| Post-Session Survey | Collect feedback after trials |
|
||||||
|
| Demographics | Collect participant information |
|
||||||
|
|
||||||
|
## Step 5: Add Participants
|
||||||
|
|
||||||
|
1. Go to **Participants** tab
|
||||||
|
2. Click **Add Participant**
|
||||||
|
3. Enter participant code (e.g., "P001")
|
||||||
|
4. Fill in optional details
|
||||||
|
|
||||||
|
### Batch Import
|
||||||
|
|
||||||
|
For large studies, import from CSV:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
participantCode,name,email,notes
|
||||||
|
P001,John Smith,john@email.com,Condition A
|
||||||
|
P002,Jane Doe,jane@email.com,Condition B
|
||||||
|
```
|
||||||
|
|
||||||
|
## Study Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Draft → Active → Recruiting → In Progress → Completed
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └── All trials done
|
||||||
|
│ │ │ └── Trials running
|
||||||
|
│ │ └── Recruiting participants
|
||||||
|
│ └── Ready to collect data
|
||||||
|
└── Setting up study
|
||||||
|
```
|
||||||
|
|
||||||
|
## Study Settings Deep Dive
|
||||||
|
|
||||||
|
### IRB Compliance
|
||||||
|
|
||||||
|
Store your IRB information:
|
||||||
|
- Protocol number
|
||||||
|
- Approval date
|
||||||
|
- Expiration date
|
||||||
|
- Consent form versions
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
Configure data retention:
|
||||||
|
- Anonymization settings
|
||||||
|
- Export formats (CSV, JSON)
|
||||||
|
- Backup frequency
|
||||||
|
|
||||||
|
### Notification Settings
|
||||||
|
|
||||||
|
Configure alerts for:
|
||||||
|
- Trial completion
|
||||||
|
- Participant issues
|
||||||
|
- Robot disconnection
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Clone a Study
|
||||||
|
|
||||||
|
Create a copy of an existing study:
|
||||||
|
|
||||||
|
1. Open the study
|
||||||
|
2. Click **Settings** (gear icon)
|
||||||
|
3. Select **Duplicate Study**
|
||||||
|
4. Enter new study name
|
||||||
|
|
||||||
|
### Archive a Study
|
||||||
|
|
||||||
|
When a study is complete:
|
||||||
|
|
||||||
|
1. Go to study settings
|
||||||
|
2. Change status to **Archived**
|
||||||
|
3. Data is preserved but study is read-only
|
||||||
|
|
||||||
|
### Transfer Ownership
|
||||||
|
|
||||||
|
Change the study owner:
|
||||||
|
|
||||||
|
1. Go to **Team** tab
|
||||||
|
2. Find the new owner
|
||||||
|
3. Click **Make Owner**
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Can't Add Team Member
|
||||||
|
|
||||||
|
- Check email is correct
|
||||||
|
- User must have an HRIStudio account
|
||||||
|
- You must be an owner or admin
|
||||||
|
|
||||||
|
### Plugin Installation Failed
|
||||||
|
|
||||||
|
- Check robot is on the network
|
||||||
|
- Verify WebSocket URL is correct
|
||||||
|
- Check Docker services are running
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that your study is set up:
|
||||||
|
|
||||||
|
1. **[Designing Experiments](03-designing-experiments.md)** - Create your first experiment protocol
|
||||||
|
2. **[Forms & Surveys](07-forms-and-surveys.md)** - Customize your consent forms
|
||||||
|
3. **[Running Trials](04-running-trials.md)** - Learn about trial management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Getting Started](01-getting-started.md) | **Next**: [Designing Experiments](03-designing-experiments.md)
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# Tutorial 3: Designing Experiments
|
||||||
|
|
||||||
|
Learn how to create experiment protocols using the visual block designer.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Navigate the experiment designer
|
||||||
|
- Use core blocks (events, wizard actions, control flow)
|
||||||
|
- Build a branching experiment protocol
|
||||||
|
|
||||||
|
## What is an Experiment?
|
||||||
|
|
||||||
|
An **Experiment** defines the protocol for your study:
|
||||||
|
|
||||||
|
```
|
||||||
|
Experiment
|
||||||
|
├── Steps (ordered sequence)
|
||||||
|
│ ├── Actions (robot behaviors)
|
||||||
|
│ ├── Wizard Blocks (human decisions)
|
||||||
|
│ └── Control Flow (loops, branches)
|
||||||
|
├── Robot Actions (from plugins)
|
||||||
|
└── Parameters (configurable values)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Create an Experiment
|
||||||
|
|
||||||
|
1. Open your study
|
||||||
|
2. Go to **Experiments** tab
|
||||||
|
3. Click **New Experiment**
|
||||||
|
|
||||||
|
### Experiment Settings
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| Name | Protocol title |
|
||||||
|
| Description | What the experiment measures |
|
||||||
|
| Robot | Which robot to use |
|
||||||
|
| Version | Track protocol versions |
|
||||||
|
|
||||||
|
## Step 2: The Experiment Designer Interface
|
||||||
|
|
||||||
|
The designer has three main areas:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Experiment: Robot Trust Study v1 [Save] │
|
||||||
|
├────────────┬─────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ Blocks │ Canvas │
|
||||||
|
│ Library │ │
|
||||||
|
│ │ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ ┌──────┐ │ │ Step 1 │───▶│ Step 2 │ │
|
||||||
|
│ │Events│ │ │ Hook │ │ Story │ │
|
||||||
|
│ ├──────┤ │ └─────────┘ └────┬────┘ │
|
||||||
|
│ │Wizard│ │ │ │
|
||||||
|
│ ├──────┤ │ ┌────▼────┐ │
|
||||||
|
│ │Control│ │ │ Step 3 │ │
|
||||||
|
│ ├──────┤ │ │ Check │ │
|
||||||
|
│ │Robot │ │ └────┬────┘ │
|
||||||
|
│ └──────┘ │ ┌────┴────┐ │
|
||||||
|
│ │ ┌────┴───┐ ┌───┴────┐ │
|
||||||
|
│ │ │Step 4a │ │Step 4b │ │
|
||||||
|
│ │ │Correct │ │ Wrong │ │
|
||||||
|
│ │ └───┬────┘ └───┬────┘ │
|
||||||
|
│ │ └─────┬─────┘ │
|
||||||
|
│ │ ┌────▼────┐ │
|
||||||
|
│ │ │ Step 5 │ │
|
||||||
|
│ │ │Conclude │ │
|
||||||
|
│ │ └─────────┘ │
|
||||||
|
├────────────┴─────────────────────────────────────────────────┤
|
||||||
|
│ Properties Panel │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Step 1: The Hook │ │
|
||||||
|
│ │ Duration: 25 seconds │ │
|
||||||
|
│ │ Actions: 2 blocks │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Understanding Block Categories
|
||||||
|
|
||||||
|
### Events (Triggers)
|
||||||
|
|
||||||
|
Start your experiment with these blocks:
|
||||||
|
|
||||||
|
| Block | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Trial Start** | Triggers when trial begins |
|
||||||
|
| **Wizard Button** | Waits for wizard to press a button |
|
||||||
|
| **Timer** | Waits for a specified duration |
|
||||||
|
| **Participant Response** | Waits for participant input |
|
||||||
|
|
||||||
|
### Wizard Actions
|
||||||
|
|
||||||
|
Blocks the wizard can control:
|
||||||
|
|
||||||
|
| Block | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Say Text** | Robot speaks text |
|
||||||
|
| **Play Animation** | Play a predefined animation |
|
||||||
|
| **Show Image** | Display image on robot screen |
|
||||||
|
| **Move Robot** | Move robot to position |
|
||||||
|
|
||||||
|
### Control Flow
|
||||||
|
|
||||||
|
Control experiment progression:
|
||||||
|
|
||||||
|
| Block | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Branch** | Split into multiple paths |
|
||||||
|
| **Loop** | Repeat a sequence |
|
||||||
|
| **Wait** | Pause for duration |
|
||||||
|
| **Converge** | Merge multiple paths back |
|
||||||
|
|
||||||
|
### Robot Actions
|
||||||
|
|
||||||
|
Actions from your installed robot plugin:
|
||||||
|
|
||||||
|
| Block | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **say_text** | Robot speaks |
|
||||||
|
| **walk_forward** | Robot walks forward |
|
||||||
|
| **turn_left** | Robot turns |
|
||||||
|
| **wave** | Robot waves |
|
||||||
|
|
||||||
|
## Step 4: Building "The Interactive Storyteller"
|
||||||
|
|
||||||
|
Let's build a simple storytelling experiment with branching:
|
||||||
|
|
||||||
|
### Step 1: The Hook (Start)
|
||||||
|
|
||||||
|
1. Click **+ Add Step**
|
||||||
|
2. Name it "The Hook"
|
||||||
|
3. Set type to **Robot**
|
||||||
|
4. Drag **Say Text** block:
|
||||||
|
```
|
||||||
|
text: "Hello! I have a story to tell you. Are you ready?"
|
||||||
|
```
|
||||||
|
5. Drag **Move Arm** block:
|
||||||
|
```
|
||||||
|
arm: right
|
||||||
|
gesture: welcome
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: The Narrative
|
||||||
|
|
||||||
|
1. Add new step "The Narrative"
|
||||||
|
2. Connect from Step 1
|
||||||
|
3. Add **Say Text**:
|
||||||
|
```
|
||||||
|
text: "Once upon a time, a traveler flew to Mars..."
|
||||||
|
```
|
||||||
|
4. Add **Turn Head** for gaze behavior:
|
||||||
|
```
|
||||||
|
yaw: 1.5
|
||||||
|
pitch: 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Comprehension Check (Branching)
|
||||||
|
|
||||||
|
1. Add new step "Comprehension Check"
|
||||||
|
2. Set type to **Conditional**
|
||||||
|
3. Add **Ask Question**:
|
||||||
|
```
|
||||||
|
question: "What color was the rock?"
|
||||||
|
options:
|
||||||
|
- Correct: "Red"
|
||||||
|
- Incorrect: "Blue"
|
||||||
|
```
|
||||||
|
4. This creates two paths automatically
|
||||||
|
|
||||||
|
### Step 4: Branch Paths
|
||||||
|
|
||||||
|
**Branch A (Correct):**
|
||||||
|
```
|
||||||
|
Say: "Yes! It was a glowing red rock."
|
||||||
|
Emotion: Happy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Branch B (Incorrect):**
|
||||||
|
```
|
||||||
|
Say: "Actually, it was red."
|
||||||
|
Emotion: Sad
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Converge
|
||||||
|
|
||||||
|
1. Add new step "Story Continues"
|
||||||
|
2. Set type to **Converge**
|
||||||
|
3. Connect both branches to this step
|
||||||
|
4. Add concluding speech
|
||||||
|
|
||||||
|
### Step 6: Conclusion
|
||||||
|
|
||||||
|
1. Add final step "Conclusion"
|
||||||
|
2. Add **Say Text**: "The End. Thank you for listening!"
|
||||||
|
3. Add **Bow** animation
|
||||||
|
|
||||||
|
## Step 5: Block Properties
|
||||||
|
|
||||||
|
Each block has configurable properties:
|
||||||
|
|
||||||
|
### Say Text Block
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello, how are you?",
|
||||||
|
"language": "en-US",
|
||||||
|
"speed": 1.0,
|
||||||
|
"emotion": "neutral"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branch Block
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"variable": "last_response",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Yes", "value": "yes", "nextStepId": "step_abc" },
|
||||||
|
{ "label": "No", "value": "no", "nextStepId": "step_xyz" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loop Block
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iterations": 3,
|
||||||
|
"maxDuration": 60,
|
||||||
|
"children": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Testing Your Experiment
|
||||||
|
|
||||||
|
### Preview Mode
|
||||||
|
|
||||||
|
Test your experiment without running a real trial:
|
||||||
|
|
||||||
|
1. Click **Preview** button
|
||||||
|
2. Step through each block
|
||||||
|
3. See timing and flow
|
||||||
|
4. Test branching decisions
|
||||||
|
|
||||||
|
### Simulation Mode
|
||||||
|
|
||||||
|
Run with a simulated robot:
|
||||||
|
|
||||||
|
1. Enable `NEXT_PUBLIC_SIMULATION_MODE=true`
|
||||||
|
2. Start a trial
|
||||||
|
3. Robot actions are logged but not executed
|
||||||
|
4. Great for protocol testing
|
||||||
|
|
||||||
|
## Advanced: Parallel Execution
|
||||||
|
|
||||||
|
Run multiple actions simultaneously:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step: Greeting
|
||||||
|
├── Parallel Block
|
||||||
|
│ ├── Say: "Hello!"
|
||||||
|
│ ├── Move Arm: Wave
|
||||||
|
│ └── Move Head: Look at participant
|
||||||
|
```
|
||||||
|
|
||||||
|
## Experiment Versioning
|
||||||
|
|
||||||
|
Track protocol changes:
|
||||||
|
|
||||||
|
1. **Draft** - Experiment being designed
|
||||||
|
2. **Testing** - Being tested with participants
|
||||||
|
3. **Ready** - Approved for data collection
|
||||||
|
4. **Deprecated** - Superseded by newer version
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Linear Protocol
|
||||||
|
|
||||||
|
```
|
||||||
|
Start → Step 1 → Step 2 → Step 3 → End
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branching Protocol
|
||||||
|
|
||||||
|
```
|
||||||
|
Start → Step 1
|
||||||
|
├── Condition A → Step 2a
|
||||||
|
└── Condition B → Step 2b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loop Protocol
|
||||||
|
|
||||||
|
```
|
||||||
|
Start → Step 1 → Loop (3x) → Step 2 → End
|
||||||
|
↑
|
||||||
|
└── (back to Step 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Protocol
|
||||||
|
|
||||||
|
```
|
||||||
|
Start → Parallel
|
||||||
|
├── Action A
|
||||||
|
├── Action B
|
||||||
|
└── Action C
|
||||||
|
→ Continue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Block Not Connecting
|
||||||
|
|
||||||
|
- Check step types are compatible
|
||||||
|
- Ensure no circular dependencies
|
||||||
|
- Verify conditions are complete
|
||||||
|
|
||||||
|
### Robot Action Not Available
|
||||||
|
|
||||||
|
- Install the robot plugin
|
||||||
|
- Check plugin is enabled for study
|
||||||
|
- Verify robot is connected
|
||||||
|
|
||||||
|
### Timing Issues
|
||||||
|
|
||||||
|
- Adjust duration estimates
|
||||||
|
- Use explicit wait blocks
|
||||||
|
- Test with real timing
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you've designed your experiment:
|
||||||
|
|
||||||
|
1. **[Running Trials](04-running-trials.md)** - Execute your experiment
|
||||||
|
2. **[Wizard Interface](05-wizard-interface.md)** - Learn real-time control
|
||||||
|
3. **[Robot Integration](06-robot-integration.md)** - Connect your robot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Your First Study](02-your-first-study.md) | **Next**: [Running Trials](04-running-trials.md)
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# Tutorial 4: Running Trials
|
||||||
|
|
||||||
|
Learn how to execute experiments and manage participant trials.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Schedule and start trials
|
||||||
|
- Monitor trial progress
|
||||||
|
- Handle trial interruptions
|
||||||
|
- Collect trial data
|
||||||
|
|
||||||
|
## What is a Trial?
|
||||||
|
|
||||||
|
A **Trial** is a single execution of an experiment with one participant:
|
||||||
|
|
||||||
|
```
|
||||||
|
Trial
|
||||||
|
├── Participant (who took part)
|
||||||
|
├── Experiment (which protocol)
|
||||||
|
├── Status (scheduled, in_progress, completed)
|
||||||
|
├── Events (timestamped actions)
|
||||||
|
└── Data (collected responses)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trial Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Scheduled → In Progress → Completed
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Aborted ◄────────┤
|
||||||
|
│ │ │
|
||||||
|
└────────► Failed ◄───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Scheduled | Trial is planned but not started |
|
||||||
|
| In Progress | Trial is currently running |
|
||||||
|
| Completed | Trial finished successfully |
|
||||||
|
| Aborted | Trial stopped early by wizard |
|
||||||
|
| Failed | Trial failed due to error |
|
||||||
|
|
||||||
|
## Step 1: Schedule a Trial
|
||||||
|
|
||||||
|
### Create Trial for Participant
|
||||||
|
|
||||||
|
1. Go to your **Study**
|
||||||
|
2. Open **Trials** tab
|
||||||
|
3. Click **Schedule Trial**
|
||||||
|
4. Select:
|
||||||
|
- **Participant**: P001
|
||||||
|
- **Experiment**: The Interactive Storyteller
|
||||||
|
- **Scheduled Time**: Today, 2:00 PM
|
||||||
|
|
||||||
|
### Batch Scheduling
|
||||||
|
|
||||||
|
For multiple participants:
|
||||||
|
|
||||||
|
1. Click **Batch Schedule**
|
||||||
|
2. Select participants (P001-P020)
|
||||||
|
3. Select experiment
|
||||||
|
4. Set time slots
|
||||||
|
|
||||||
|
```
|
||||||
|
| Time | Participant |
|
||||||
|
|------------|-------------|
|
||||||
|
| 2:00 PM | P001 |
|
||||||
|
| 2:15 PM | P002 |
|
||||||
|
| 2:30 PM | P003 |
|
||||||
|
| ... | ... |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Prepare for Trial
|
||||||
|
|
||||||
|
Before starting:
|
||||||
|
|
||||||
|
1. **Verify Robot Connection**
|
||||||
|
- Check robot is powered on
|
||||||
|
- Verify network connection
|
||||||
|
- Test WebSocket connection
|
||||||
|
|
||||||
|
2. **Review Experiment**
|
||||||
|
- Ensure experiment is "Ready" status
|
||||||
|
- Check step count and timing
|
||||||
|
- Verify all actions are configured
|
||||||
|
|
||||||
|
3. **Prepare Environment**
|
||||||
|
- Ensure participant consent is obtained
|
||||||
|
- Set up recording equipment (if needed)
|
||||||
|
- Remove distractions
|
||||||
|
|
||||||
|
## Step 3: Start a Trial
|
||||||
|
|
||||||
|
### From Trials List
|
||||||
|
|
||||||
|
1. Find the scheduled trial
|
||||||
|
2. Click **Start Trial**
|
||||||
|
3. Confirm participant is ready
|
||||||
|
4. Click **Begin**
|
||||||
|
|
||||||
|
### From Wizard Interface
|
||||||
|
|
||||||
|
1. Open **Wizard Interface**
|
||||||
|
2. Select trial from queue
|
||||||
|
3. Click **Start**
|
||||||
|
|
||||||
|
## Step 4: During the Trial
|
||||||
|
|
||||||
|
### Wizard Interface Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Trial: P001 - Interactive Storyteller [00:05:23]│
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌──────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Trial │ │ Timeline │ │ Robot Control │ │
|
||||||
|
│ │ Controls │ │ │ │ │ │
|
||||||
|
│ │ │ │ ●───●───○───○ │ │ ┌─────────────┐ │ │
|
||||||
|
│ │ [▶ Play] │ │ Step 1 2 3 4 │ │ │ Connected ✓ │ │ │
|
||||||
|
│ │ [⏸ Pause] │ │ ↑ │ │ │ Battery: 85%│ │ │
|
||||||
|
│ │ [⏹ Stop] │ │ Current: Step 2 │ │ └─────────────┘ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ [📝 Notes] │ │ Progress: 40% │ │ [Say Text] │ │
|
||||||
|
│ │ [⚠ Alert] │ │ │ │ [Move Robot] │ │
|
||||||
|
│ └──────────────┘ └────────────────────┘ │ [Custom Action]│ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trial Controls
|
||||||
|
|
||||||
|
| Button | Action | Keyboard |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Play | Resume trial | Space |
|
||||||
|
| Pause | Pause trial | Space |
|
||||||
|
| Stop | End trial early | Escape |
|
||||||
|
| Notes | Add timestamped note | N |
|
||||||
|
| Alert | Send alert notification | A |
|
||||||
|
|
||||||
|
### Monitoring Progress
|
||||||
|
|
||||||
|
**Timeline View:**
|
||||||
|
- Visual step progression
|
||||||
|
- Current step highlighted
|
||||||
|
- Completed steps checked
|
||||||
|
- Estimated time remaining
|
||||||
|
|
||||||
|
**Event Log:**
|
||||||
|
- Timestamped events
|
||||||
|
- Action executions
|
||||||
|
- Wizard interventions
|
||||||
|
- Robot responses
|
||||||
|
|
||||||
|
## Step 5: Wizard Interventions
|
||||||
|
|
||||||
|
During Wizard-of-Oz studies, wizards can intervene:
|
||||||
|
|
||||||
|
### Add Intervention
|
||||||
|
|
||||||
|
1. Click **+ Intervention**
|
||||||
|
2. Select type:
|
||||||
|
- **Pause**: Temporarily stop trial
|
||||||
|
- **Resume**: Continue after pause
|
||||||
|
- **Note**: Add observation
|
||||||
|
- **Alert**: Notify researcher
|
||||||
|
|
||||||
|
### Branch Selection
|
||||||
|
|
||||||
|
When reaching a conditional step:
|
||||||
|
|
||||||
|
1. Observe participant response
|
||||||
|
2. Select appropriate branch:
|
||||||
|
- **Correct**: Proceed to positive path
|
||||||
|
- **Incorrect**: Proceed to correction path
|
||||||
|
3. Select is logged for analysis
|
||||||
|
|
||||||
|
### Manual Actions
|
||||||
|
|
||||||
|
Execute unplanned actions:
|
||||||
|
|
||||||
|
1. Click **+ Action**
|
||||||
|
2. Select from robot actions
|
||||||
|
3. Configure parameters
|
||||||
|
4. Execute immediately
|
||||||
|
|
||||||
|
## Step 6: Trial Completion
|
||||||
|
|
||||||
|
### Automatic Completion
|
||||||
|
|
||||||
|
When all steps complete:
|
||||||
|
1. Final step executes
|
||||||
|
2. Trial status → "Completed"
|
||||||
|
3. Data is saved automatically
|
||||||
|
4. Summary shown
|
||||||
|
|
||||||
|
### Manual Completion
|
||||||
|
|
||||||
|
To end early:
|
||||||
|
|
||||||
|
1. Click **Stop Trial**
|
||||||
|
2. Confirm completion
|
||||||
|
3. Select reason:
|
||||||
|
- Participant fatigue
|
||||||
|
- Technical issue
|
||||||
|
- Protocol complete
|
||||||
|
4. Save partial data
|
||||||
|
|
||||||
|
## Step 7: Post-Trial
|
||||||
|
|
||||||
|
### Automatic Prompts
|
||||||
|
|
||||||
|
After trial completion:
|
||||||
|
|
||||||
|
1. **Participant Debrief**
|
||||||
|
- Thank participant
|
||||||
|
- Answer questions
|
||||||
|
- Collect final feedback
|
||||||
|
|
||||||
|
2. **Survey Distribution**
|
||||||
|
- Send post-session survey
|
||||||
|
- Collect responses
|
||||||
|
|
||||||
|
3. **Data Export**
|
||||||
|
- Download trial data
|
||||||
|
- Export event log
|
||||||
|
|
||||||
|
### Trial Summary
|
||||||
|
|
||||||
|
View trial summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Trial Summary - P001 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Duration: 5:23 │
|
||||||
|
│ Steps Completed: 6/6 (100%) │
|
||||||
|
│ Interventions: 2 │
|
||||||
|
│ │
|
||||||
|
│ Actions: │
|
||||||
|
│ ✓ Say Text: "Hello..." (2.3s) │
|
||||||
|
│ ✓ Turn Head: yaw=1.5 (1.1s) │
|
||||||
|
│ ✓ Say Text: "What color..." (3.2s) │
|
||||||
|
│ ⚠ Intervention: Pause (10s) │
|
||||||
|
│ ✓ Branch: Correct selected │
|
||||||
|
│ ✓ Say Text: "Yes! It was red" (2.8s) │
|
||||||
|
│ │
|
||||||
|
│ Events: 18 logged │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managing Multiple Trials
|
||||||
|
|
||||||
|
### Trial Queue
|
||||||
|
|
||||||
|
View upcoming trials:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Trial Queue [Refresh] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 2:00 PM │ P001 │ Interactive Storyteller │ Scheduled │
|
||||||
|
│ 2:20 PM │ P002 │ Interactive Storyteller │ Scheduled │
|
||||||
|
│ 2:40 PM │ P003 │ Interactive Storyteller │ Scheduled │
|
||||||
|
│ 3:00 PM │ P004 │ Interactive Storyteller │ Scheduled │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trial History
|
||||||
|
|
||||||
|
View past trials:
|
||||||
|
|
||||||
|
| Participant | Started | Duration | Status | Interventions |
|
||||||
|
|-------------|---------|----------|--------|---------------|
|
||||||
|
| P001 | Today 2:00 PM | 5:23 | Completed | 2 |
|
||||||
|
| P002 | Today 2:20 PM | 4:58 | Completed | 1 |
|
||||||
|
| P003 | Today 2:45 PM | - | In Progress | 0 |
|
||||||
|
|
||||||
|
## Data Collection
|
||||||
|
|
||||||
|
### Automatic Data Capture
|
||||||
|
|
||||||
|
HRIStudio automatically logs:
|
||||||
|
|
||||||
|
- Timestamps for all events
|
||||||
|
- Action executions
|
||||||
|
- Robot responses
|
||||||
|
- Wizard interventions
|
||||||
|
- Participant responses
|
||||||
|
- Timing data
|
||||||
|
|
||||||
|
### Manual Data
|
||||||
|
|
||||||
|
Wizards can add:
|
||||||
|
|
||||||
|
- Timestamped notes
|
||||||
|
- Observation categories
|
||||||
|
- Participant behavior codes
|
||||||
|
- Custom annotations
|
||||||
|
|
||||||
|
### Export Formats
|
||||||
|
|
||||||
|
Download trial data:
|
||||||
|
|
||||||
|
| Format | Contents |
|
||||||
|
|--------|----------|
|
||||||
|
| CSV | Tabular data for spreadsheets |
|
||||||
|
| JSON | Full event log with metadata |
|
||||||
|
| Video | Screen recording (if enabled) |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Trial Won't Start
|
||||||
|
|
||||||
|
1. Check robot connection
|
||||||
|
2. Verify experiment is "Ready"
|
||||||
|
3. Check participant consent
|
||||||
|
4. Review error logs
|
||||||
|
|
||||||
|
### Trial Paused Unexpectedly
|
||||||
|
|
||||||
|
- Robot may have disconnected
|
||||||
|
- Check network connection
|
||||||
|
- Resume when connection restored
|
||||||
|
|
||||||
|
### Data Not Saved
|
||||||
|
|
||||||
|
- Ensure database connection
|
||||||
|
- Check disk space
|
||||||
|
- Export data manually
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Trials
|
||||||
|
|
||||||
|
- [ ] Robot connected and tested
|
||||||
|
- [ ] Experiment verified
|
||||||
|
- [ ] Participant consent obtained
|
||||||
|
- [ ] Recording equipment ready
|
||||||
|
- [ ] Wizard briefed on protocol
|
||||||
|
|
||||||
|
### During Trials
|
||||||
|
|
||||||
|
- [ ] Monitor timeline progress
|
||||||
|
- [ ] Take timestamped notes
|
||||||
|
- [ ] Document interventions
|
||||||
|
- [ ] Watch for issues
|
||||||
|
|
||||||
|
### After Trials
|
||||||
|
|
||||||
|
- [ ] Review trial summary
|
||||||
|
- [ ] Export data promptly
|
||||||
|
- [ ] Send follow-up surveys
|
||||||
|
- [ ] Update participant status
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you can run trials:
|
||||||
|
|
||||||
|
1. **[Wizard Interface](05-wizard-interface.md)** - Master real-time control
|
||||||
|
2. **[Data & Analysis](08-data-and-analysis.md)** - Analyze your results
|
||||||
|
3. **[Forms & Surveys](07-forms-and-surveys.md)** - Collect post-trial data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Designing Experiments](03-designing-experiments.md) | **Next**: [Wizard Interface](05-wizard-interface.md)
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# Tutorial 5: Wizard Interface
|
||||||
|
|
||||||
|
Learn how to use the real-time wizard control interface for Wizard-of-Oz studies.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Navigate the wizard interface
|
||||||
|
- Control robot actions in real-time
|
||||||
|
- Make branching decisions
|
||||||
|
- Handle trial interruptions
|
||||||
|
|
||||||
|
## What is the Wizard Interface?
|
||||||
|
|
||||||
|
The **Wizard Interface** is your control center during trials. It provides:
|
||||||
|
|
||||||
|
- Real-time trial monitoring
|
||||||
|
- Robot action controls
|
||||||
|
- Decision-making tools
|
||||||
|
- Intervention capabilities
|
||||||
|
- Event logging
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ WIZARD INTERFACE │
|
||||||
|
├────────────────┬─────────────────────┬──────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ Trial │ Timeline │ Robot │
|
||||||
|
│ Controls │ Progress │ Status │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────┐ │ ┌───────────────┐ │ ┌────────────────┐ │
|
||||||
|
│ │ ▶ Play │ │ │ 1 → 2 → 3 → │ │ │ ● Connected │ │
|
||||||
|
│ │ ⏸ Pause │ │ │ ↑ │ │ │ Battery: 85% │ │
|
||||||
|
│ │ ⏹ Stop │ │ │ Step 2 │ │ │ Position: (0,0)│ │
|
||||||
|
│ └──────────┘ │ └───────────────┘ │ └────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────┐ │ Progress: 40% │ ┌────────────────┐ │
|
||||||
|
│ │ 📝 Notes │ │ Time: 00:05:23 │ │ Action Panel │ │
|
||||||
|
│ │ ⚠ Alert │ │ │ │ │ │
|
||||||
|
│ └──────────┘ │ │ │ [Say Text] │ │
|
||||||
|
│ │ │ │ [Move Robot] │ │
|
||||||
|
│ │ │ │ [Wave] │ │
|
||||||
|
│ │ │ │ [Custom...] │ │
|
||||||
|
│ │ │ └────────────────┘ │
|
||||||
|
└────────────────┴─────────────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Accessing the Wizard Interface
|
||||||
|
|
||||||
|
### Method 1: From Trials List
|
||||||
|
|
||||||
|
1. Go to **Trials** in sidebar
|
||||||
|
2. Find your scheduled trial
|
||||||
|
3. Click **Open Wizard**
|
||||||
|
|
||||||
|
### Method 2: Direct URL
|
||||||
|
|
||||||
|
```
|
||||||
|
/trials/{trialId}/wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: Trial Queue
|
||||||
|
|
||||||
|
1. Go to **Wizard Queue**
|
||||||
|
2. See all pending trials
|
||||||
|
3. Click **Start** on any trial
|
||||||
|
|
||||||
|
## Step 2: Understanding the Layout
|
||||||
|
|
||||||
|
### Left Panel: Trial Controls
|
||||||
|
|
||||||
|
| Control | Function |
|
||||||
|
|---------|----------|
|
||||||
|
| Play/Pause | Start or pause trial |
|
||||||
|
| Stop | End trial early |
|
||||||
|
| Notes | Add timestamped observations |
|
||||||
|
| Alert | Send alert to researchers |
|
||||||
|
|
||||||
|
### Center Panel: Timeline
|
||||||
|
|
||||||
|
- **Visual Progress**: See step progression
|
||||||
|
- **Current Position**: Highlighted current step
|
||||||
|
- **Navigation**: Click to jump to step (if allowed)
|
||||||
|
- **Time Display**: Elapsed and estimated remaining
|
||||||
|
|
||||||
|
### Right Panel: Robot Control
|
||||||
|
|
||||||
|
**Status Section:**
|
||||||
|
- Connection indicator
|
||||||
|
- Battery level
|
||||||
|
- Position tracking
|
||||||
|
- Sensor readings
|
||||||
|
|
||||||
|
**Action Section:**
|
||||||
|
- Quick action buttons
|
||||||
|
- Custom action builder
|
||||||
|
- Action history
|
||||||
|
|
||||||
|
## Step 3: Controlling the Robot
|
||||||
|
|
||||||
|
### Quick Actions
|
||||||
|
|
||||||
|
Pre-configured robot actions:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Say Text | Make robot speak |
|
||||||
|
| Wave | Wave gesture |
|
||||||
|
| Look at Me | Turn head toward participant |
|
||||||
|
| Look Away | Turn head elsewhere |
|
||||||
|
| Nod | Confirmation nod |
|
||||||
|
| Shake Head | Negation shake |
|
||||||
|
|
||||||
|
### Custom Say Text
|
||||||
|
|
||||||
|
1. Click **Say Text**
|
||||||
|
2. Enter text in popup:
|
||||||
|
```
|
||||||
|
"Hello! Nice to meet you."
|
||||||
|
```
|
||||||
|
3. Select options:
|
||||||
|
- Speed: Normal / Slow / Fast
|
||||||
|
- Emotion: Neutral / Happy / Excited
|
||||||
|
4. Click **Execute**
|
||||||
|
5. Robot speaks the text
|
||||||
|
|
||||||
|
### Move Robot
|
||||||
|
|
||||||
|
1. Click **Move Robot**
|
||||||
|
2. Select movement type:
|
||||||
|
- Walk Forward/Back
|
||||||
|
- Turn Left/Right
|
||||||
|
- Move Head
|
||||||
|
- Move Arm
|
||||||
|
3. Set parameters
|
||||||
|
4. Execute
|
||||||
|
|
||||||
|
### Custom Actions
|
||||||
|
|
||||||
|
For advanced control:
|
||||||
|
|
||||||
|
1. Click **Custom...**
|
||||||
|
2. Select action from plugin
|
||||||
|
3. Configure parameters
|
||||||
|
4. Execute
|
||||||
|
|
||||||
|
## Step 4: Making Decisions
|
||||||
|
|
||||||
|
When the experiment reaches a branching point:
|
||||||
|
|
||||||
|
### Decision Popup
|
||||||
|
|
||||||
|
A popup appears with options:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Branch Decision Required │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Step: Comprehension Check │
|
||||||
|
│ Question: "What color was the rock?" │
|
||||||
|
│ │
|
||||||
|
│ Participant's response: They said "blue" (incorrect) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ○ Correct Response (Red) │ │
|
||||||
|
│ │ → Robot celebrates │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ● Incorrect Response (Other) │ │
|
||||||
|
│ │ → Robot gently corrects │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Confirm Selection] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision Guidelines
|
||||||
|
|
||||||
|
1. **Observe** participant's actual response
|
||||||
|
2. **Consider** protocol criteria
|
||||||
|
3. **Select** appropriate branch
|
||||||
|
4. **Confirm** selection
|
||||||
|
|
||||||
|
### After Selection
|
||||||
|
|
||||||
|
- Decision is logged with timestamp
|
||||||
|
- Trial continues on selected path
|
||||||
|
- Both participant and robot continue
|
||||||
|
|
||||||
|
## Step 5: Handling Interruptions
|
||||||
|
|
||||||
|
### Pause Trial
|
||||||
|
|
||||||
|
When you need to pause:
|
||||||
|
|
||||||
|
1. Click **Pause** button
|
||||||
|
2. Optionally add reason:
|
||||||
|
- Participant needs break
|
||||||
|
- Technical issue
|
||||||
|
- External interruption
|
||||||
|
3. Trial pauses, robot holds position
|
||||||
|
|
||||||
|
### Resume Trial
|
||||||
|
|
||||||
|
1. Click **Play** button
|
||||||
|
2. Trial resumes from pause point
|
||||||
|
3. Pause duration is logged
|
||||||
|
|
||||||
|
### Stop Trial
|
||||||
|
|
||||||
|
For early termination:
|
||||||
|
|
||||||
|
1. Click **Stop** button
|
||||||
|
2. Select reason:
|
||||||
|
- Participant fatigue
|
||||||
|
- Technical failure
|
||||||
|
- Protocol deviation
|
||||||
|
- Participant withdrawal
|
||||||
|
3. Confirm stop
|
||||||
|
4. Partial data is saved
|
||||||
|
|
||||||
|
### Add Notes
|
||||||
|
|
||||||
|
Record observations:
|
||||||
|
|
||||||
|
1. Click **Notes** button
|
||||||
|
2. Enter observation:
|
||||||
|
```
|
||||||
|
Participant laughed at the robot's gesture.
|
||||||
|
```
|
||||||
|
3. Note is timestamped automatically
|
||||||
|
4. Notes appear in event log
|
||||||
|
|
||||||
|
### Send Alert
|
||||||
|
|
||||||
|
Notify researchers:
|
||||||
|
|
||||||
|
1. Click **Alert** button
|
||||||
|
2. Select alert type:
|
||||||
|
- Technical issue
|
||||||
|
- Safety concern
|
||||||
|
- Protocol question
|
||||||
|
- Other
|
||||||
|
3. Add description
|
||||||
|
4. Send alert
|
||||||
|
|
||||||
|
## Step 6: Monitoring Robot Status
|
||||||
|
|
||||||
|
### Connection Status
|
||||||
|
|
||||||
|
| Status | Icon | Meaning |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Connected | ● Green | Robot responding |
|
||||||
|
| Connecting | ● Yellow | Attempting connection |
|
||||||
|
| Disconnected | ● Red | No robot connection |
|
||||||
|
| Error | ⚠ Orange | Connection error |
|
||||||
|
|
||||||
|
### Battery Monitor
|
||||||
|
|
||||||
|
View battery level:
|
||||||
|
- Green: > 50%
|
||||||
|
- Yellow: 20-50%
|
||||||
|
- Red: < 20%
|
||||||
|
|
||||||
|
### Sensor Display
|
||||||
|
|
||||||
|
Real-time sensor readings:
|
||||||
|
- Joint positions
|
||||||
|
- Touch sensors
|
||||||
|
- Sonar distances
|
||||||
|
- Camera feed (if available)
|
||||||
|
|
||||||
|
### Action Queue
|
||||||
|
|
||||||
|
See pending/executing actions:
|
||||||
|
```
|
||||||
|
Executing: Say Text "Hello!"
|
||||||
|
Pending: Move Head (queued)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Keyboard Shortcuts
|
||||||
|
|
||||||
|
Speed up your workflow:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Space | Play/Pause toggle |
|
||||||
|
| Escape | Stop trial |
|
||||||
|
| N | Add note |
|
||||||
|
| A | Send alert |
|
||||||
|
| 1-9 | Execute quick action |
|
||||||
|
| ← → | Navigate timeline |
|
||||||
|
| ↑ ↓ | Select branch option |
|
||||||
|
|
||||||
|
## Step 8: Event Logging
|
||||||
|
|
||||||
|
All actions are logged automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
[14:32:05] Trial started
|
||||||
|
[14:32:07] Step 1: The Hook
|
||||||
|
[14:32:08] Action: Say Text "Hello!"
|
||||||
|
[14:32:11] Action: Move Arm Wave
|
||||||
|
[14:32:15] Step 2: The Narrative
|
||||||
|
[14:32:16] Action: Say Text "Once upon a time..."
|
||||||
|
[14:33:05] Step 3: Comprehension Check
|
||||||
|
[14:33:06] Action: Say Text "What color was the rock?"
|
||||||
|
[14:33:28] Wizard Note: "Participant said blue"
|
||||||
|
[14:33:30] Branch: Incorrect selected
|
||||||
|
[14:33:31] Step 4b: Correction
|
||||||
|
[14:33:32] Action: Say Text "Actually, it was red."
|
||||||
|
[14:34:05] Trial completed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trial Modes
|
||||||
|
|
||||||
|
### Observer Mode
|
||||||
|
|
||||||
|
For observers (read-only):
|
||||||
|
- View trial progress
|
||||||
|
- See robot status
|
||||||
|
- Cannot execute actions
|
||||||
|
- Can add notes
|
||||||
|
|
||||||
|
### Active Wizard Mode
|
||||||
|
|
||||||
|
Full control:
|
||||||
|
- Execute actions
|
||||||
|
- Make decisions
|
||||||
|
- Pause/resume
|
||||||
|
- Add notes/alerts
|
||||||
|
|
||||||
|
### Training Mode
|
||||||
|
|
||||||
|
Practice without real data:
|
||||||
|
- Simulated robot
|
||||||
|
- No data saved
|
||||||
|
- Safe to experiment
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Trial
|
||||||
|
|
||||||
|
- [ ] Review experiment protocol
|
||||||
|
- [ ] Test robot connection
|
||||||
|
- [ ] Familiarize with action panel
|
||||||
|
- [ ] Know decision criteria
|
||||||
|
|
||||||
|
### During Trial
|
||||||
|
|
||||||
|
- [ ] Stay focused on participant
|
||||||
|
- [ ] Make decisions based on observation
|
||||||
|
- [ ] Document notable events
|
||||||
|
- [ ] Keep action log clean
|
||||||
|
|
||||||
|
### After Trial
|
||||||
|
|
||||||
|
- [ ] Review event log
|
||||||
|
- [ ] Add final notes
|
||||||
|
- [ ] Confirm data saved
|
||||||
|
- [ ] Prepare for next trial
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Robot Not Responding
|
||||||
|
|
||||||
|
1. Check connection indicator
|
||||||
|
2. Verify network
|
||||||
|
3. Check robot power
|
||||||
|
4. Restart connection
|
||||||
|
|
||||||
|
### Actions Not Executing
|
||||||
|
|
||||||
|
1. Check action queue
|
||||||
|
2. Verify parameters
|
||||||
|
3. Check robot state (not in rest mode)
|
||||||
|
|
||||||
|
### Decision Popup Not Appearing
|
||||||
|
|
||||||
|
1. Check if step has branches
|
||||||
|
2. Verify step type is "conditional"
|
||||||
|
3. Contact researcher
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Mastered the wizard interface?
|
||||||
|
|
||||||
|
1. **[Robot Integration](06-robot-integration.md)** - Deep dive into robot control
|
||||||
|
2. **[Data & Analysis](08-data-and-analysis.md)** - Review trial data
|
||||||
|
3. **[Simulation Mode](09-simulation-mode.md)** - Practice without a robot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Running Trials](04-running-trials.md) | **Next**: [Robot Integration](06-robot-integration.md)
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Tutorial 6: Robot Integration
|
||||||
|
|
||||||
|
Learn how to connect and configure robots for your HRI studies.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Connect NAO6 robot to HRIStudio
|
||||||
|
- Configure robot plugins
|
||||||
|
- Test robot connection
|
||||||
|
- Troubleshoot common issues
|
||||||
|
|
||||||
|
## Supported Robots
|
||||||
|
|
||||||
|
HRIStudio supports multiple robot platforms:
|
||||||
|
|
||||||
|
| Robot | Protocol | Actions |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| **NAO6** | ROS2 | Speech, movement, gestures, sensors |
|
||||||
|
| **TurtleBot3** | ROS2 | Navigation, sensors |
|
||||||
|
| **Mock Robot** | WebSocket | All actions (simulation) |
|
||||||
|
|
||||||
|
## Understanding the Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ HRIStudio Platform │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Wizard │◄────────────►│ Robot Communication │ │
|
||||||
|
│ │ Interface │ WebSocket │ Service │ │
|
||||||
|
│ └──────────────┘ └──────────┬───────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ROS Bridge │
|
||||||
|
│ ┌─────▼─────┐ │
|
||||||
|
│ │ rosbridge │ │
|
||||||
|
│ │ :9090 │ │
|
||||||
|
│ └─────┬─────┘ │
|
||||||
|
└────────────────────────────────────────────┼─────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────▼─────┐ ┌─────▼─────┐ │
|
||||||
|
│ NAO │ │ NAO │ │
|
||||||
|
│ Driver │ │ Robot │ │
|
||||||
|
│ (ROS2) │◄───►│ (naoqi) │ │
|
||||||
|
└───────────┘ └───────────┘ │
|
||||||
|
Network Robot │
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Set Up NAO6 Robot
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
1. Connect NAO6 to your network:
|
||||||
|
```
|
||||||
|
# On the robot, say "Connect to Wi-Fi"
|
||||||
|
# Or use the Choregraphe interface
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Note the robot's IP address:
|
||||||
|
```
|
||||||
|
# On the robot, say "What is my IP address?"
|
||||||
|
# Or check robot's network settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify network access:
|
||||||
|
```bash
|
||||||
|
ping nao.local
|
||||||
|
# Or ping the IP directly:
|
||||||
|
ping 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Robot Credentials
|
||||||
|
|
||||||
|
Default credentials:
|
||||||
|
```
|
||||||
|
Username: nao
|
||||||
|
Password: robolab
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wake Up Robot
|
||||||
|
|
||||||
|
Before connecting, wake up the robot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh nao@192.168.1.100
|
||||||
|
# Enter password when prompted
|
||||||
|
|
||||||
|
# Wake up the robot
|
||||||
|
python -c "from naoqi import ALProxy; proxy = ALProxy('ALMotion', '192.168.1.100', 9559); proxy.wakeUp()"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Start Docker Services
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/nao6-hristudio-integration
|
||||||
|
|
||||||
|
# Set robot IP
|
||||||
|
export NAO_IP=192.168.1.100
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services Overview
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `nao_driver` | - | ROS2 driver for NAO |
|
||||||
|
| `ros_bridge` | 9090 | WebSocket bridge |
|
||||||
|
| `ros_api` | - | Topic introspection |
|
||||||
|
|
||||||
|
### Verify Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check running containers
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Test WebSocket connection
|
||||||
|
ws://localhost:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Configure HRIStudio
|
||||||
|
|
||||||
|
### Install Robot Plugin
|
||||||
|
|
||||||
|
1. Go to **Plugins** in sidebar
|
||||||
|
2. Select your study
|
||||||
|
3. Click **Browse Plugins**
|
||||||
|
4. Find **NAO6 Robot (ROS2 Integration)**
|
||||||
|
5. Click **Install**
|
||||||
|
|
||||||
|
### Configure Plugin
|
||||||
|
|
||||||
|
Set robot connection:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ NAO6 Robot Configuration │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Robot Name: NAO6-Lab │
|
||||||
|
│ Robot IP: 192.168.1.100 │
|
||||||
|
│ WebSocket URL: ws://localhost:9090 │
|
||||||
|
│ │
|
||||||
|
│ Advanced Settings: │
|
||||||
|
│ □ Use Simulation Mode │
|
||||||
|
│ Connection Timeout: 30 seconds │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create `hristudio/.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Robot connection
|
||||||
|
NAO_ROBOT_IP=192.168.1.100
|
||||||
|
NAO_PASSWORD=robolab
|
||||||
|
NAO_USERNAME=nao
|
||||||
|
|
||||||
|
# WebSocket bridge
|
||||||
|
NEXT_PUBLIC_ROS_BRIDGE_URL=ws://localhost:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Test Connection
|
||||||
|
|
||||||
|
### Using the NAO Test Page
|
||||||
|
|
||||||
|
1. Navigate to: `http://localhost:3000/nao-test`
|
||||||
|
2. Click **Connect**
|
||||||
|
3. Verify connection status
|
||||||
|
|
||||||
|
### Connection Status Indicators
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| **Connected** | Robot responding normally |
|
||||||
|
| **Connecting** | Attempting connection |
|
||||||
|
| **Error** | Connection failed |
|
||||||
|
| **Timeout** | Robot not responding |
|
||||||
|
|
||||||
|
### Test Actions
|
||||||
|
|
||||||
|
Test basic robot actions:
|
||||||
|
|
||||||
|
| Action | Expected Behavior |
|
||||||
|
|--------|-------------------|
|
||||||
|
| Say Text | Robot speaks |
|
||||||
|
| Wave | Robot waves arm |
|
||||||
|
| Walk Forward | Robot walks |
|
||||||
|
| Turn Left | Robot turns |
|
||||||
|
|
||||||
|
## Step 5: Robot Actions Reference
|
||||||
|
|
||||||
|
### Speech Actions
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `say_text` | `text` | Speak text |
|
||||||
|
| `say_with_emotion` | `text`, `emotion` | Emotional speech |
|
||||||
|
| `set_volume` | `level` | Set speech volume |
|
||||||
|
| `set_language` | `language` | Set speech language |
|
||||||
|
|
||||||
|
### Movement Actions
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `walk_forward` | `speed`, `duration` | Walk forward |
|
||||||
|
| `walk_backward` | `speed` | Walk backward |
|
||||||
|
| `turn_left` | `speed` | Turn left |
|
||||||
|
| `turn_right` | `speed` | Turn right |
|
||||||
|
| `stop` | - | Stop all movement |
|
||||||
|
|
||||||
|
### Head Actions
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `move_head` | `yaw`, `pitch`, `speed` | Move head to position |
|
||||||
|
| `turn_head` | `yaw`, `pitch` | Turn head (relative) |
|
||||||
|
|
||||||
|
### Arm Actions
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `move_arm` | `arm`, joint angles | Move arm to position |
|
||||||
|
| `wave` | `arm` | Wave gesture |
|
||||||
|
|
||||||
|
### Autonomous Life
|
||||||
|
|
||||||
|
| Action | Parameters | Description |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `wake_up` | - | Wake robot from rest |
|
||||||
|
| `rest` | - | Put robot to rest |
|
||||||
|
| `set_autonomous_life` | `enabled` | Toggle autonomous behavior |
|
||||||
|
|
||||||
|
## Step 6: Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Robot Not Found
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Cannot connect to robot at 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify IP address: `ping 192.168.1.100`
|
||||||
|
2. Check robot is powered on
|
||||||
|
3. Verify network connectivity
|
||||||
|
4. Try `nao.local` hostname
|
||||||
|
|
||||||
|
#### WebSocket Connection Failed
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: WebSocket connection to ws://localhost:9090 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check Docker is running
|
||||||
|
2. Verify ros_bridge container: `docker ps`
|
||||||
|
3. Check port 9090 is not blocked
|
||||||
|
4. Restart services: `docker compose restart`
|
||||||
|
|
||||||
|
#### Robot Not Responding
|
||||||
|
|
||||||
|
Robot connected but actions don't execute.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Wake up robot: `ssh nao@IP python -c "from naoqi import ALProxy; p=ALProxy('ALMotion','IP',9559);p.wakeUp()"`
|
||||||
|
2. Check robot is not in rest mode
|
||||||
|
3. Verify no blocking software on robot
|
||||||
|
|
||||||
|
#### Action Timeout
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Action timed out after 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Robot may be busy with previous action
|
||||||
|
2. Check network latency
|
||||||
|
3. Increase timeout in settings
|
||||||
|
|
||||||
|
### Diagnostic Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker containers
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# View all logs
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# View specific service
|
||||||
|
docker compose logs ros_bridge
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# Stop and start fresh
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check robot IP
|
||||||
|
ssh nao@IP "ifconfig"
|
||||||
|
|
||||||
|
# Test from robot
|
||||||
|
ssh nao@IP "curl localhost:9090"
|
||||||
|
|
||||||
|
# Check firewall
|
||||||
|
sudo iptables -L
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Robot Maintenance
|
||||||
|
|
||||||
|
### Battery Management
|
||||||
|
|
||||||
|
- Check battery before each session
|
||||||
|
- Aim for >50% battery
|
||||||
|
- Charge during breaks
|
||||||
|
- Replace battery if <20% capacity
|
||||||
|
|
||||||
|
### Calibration
|
||||||
|
|
||||||
|
Periodically calibrate:
|
||||||
|
- Joint positions
|
||||||
|
- Camera alignment
|
||||||
|
- Touch sensors
|
||||||
|
- Sound localization
|
||||||
|
|
||||||
|
### Software Updates
|
||||||
|
|
||||||
|
Keep robot software updated:
|
||||||
|
- NAOqi version
|
||||||
|
- ROS2 packages
|
||||||
|
- HRIStudio plugin
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
- Use encrypted network (WPA2/WPA3)
|
||||||
|
- Firewall robot from internet
|
||||||
|
- Use strong passwords
|
||||||
|
|
||||||
|
### SSH Access
|
||||||
|
|
||||||
|
- Change default passwords
|
||||||
|
- Use SSH keys when possible
|
||||||
|
- Limit SSH access
|
||||||
|
|
||||||
|
### Data Security
|
||||||
|
|
||||||
|
- Robot camera data may be sensitive
|
||||||
|
- Store data securely
|
||||||
|
- Follow IRB guidelines
|
||||||
|
|
||||||
|
## Simulation Mode
|
||||||
|
|
||||||
|
For testing without a robot:
|
||||||
|
|
||||||
|
1. Enable simulation mode in settings
|
||||||
|
2. Or set `NEXT_PUBLIC_SIMULATION_MODE=true`
|
||||||
|
3. All actions are simulated locally
|
||||||
|
|
||||||
|
See [Simulation Mode Tutorial](09-simulation-mode.md) for details.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that your robot is connected:
|
||||||
|
|
||||||
|
1. **[Running Trials](04-running-trials.md)** - Execute trials with robot
|
||||||
|
2. **[Wizard Interface](05-wizard-interface.md)** - Control the robot
|
||||||
|
3. **[Data & Analysis](08-data-and-analysis.md)** - Collect interaction data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Wizard Interface](05-wizard-interface.md) | **Next**: [Forms & Surveys](07-forms-and-surveys.md)
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# Tutorial 7: Forms & Surveys
|
||||||
|
|
||||||
|
Learn how to create and manage consent forms, surveys, and questionnaires.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Create consent forms for IRB compliance
|
||||||
|
- Build post-session surveys
|
||||||
|
- Collect participant responses
|
||||||
|
- Manage form templates
|
||||||
|
|
||||||
|
## Form Types
|
||||||
|
|
||||||
|
HRIStudio supports three form types:
|
||||||
|
|
||||||
|
| Type | Purpose | When |
|
||||||
|
|------|---------|------|
|
||||||
|
| **Consent** | Informed consent for participation | Before trial |
|
||||||
|
| **Survey** | Collect feedback and observations | After trial |
|
||||||
|
| **Questionnaire** | Demographic data collection | Any time |
|
||||||
|
|
||||||
|
## Step 1: Access Forms
|
||||||
|
|
||||||
|
1. Go to your **Study**
|
||||||
|
2. Click **Forms** tab
|
||||||
|
3. View existing forms and templates
|
||||||
|
|
||||||
|
### Form List View
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Forms [+ Create] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Name Type Responses Status │
|
||||||
|
│ ─────────────────────────────────────────────────────────── │
|
||||||
|
│ Informed Consent Consent 12/20 Active │
|
||||||
|
│ Post-Session Survey Survey 8/20 Active │
|
||||||
|
│ Demographics Questionnaire 15/20 Active │
|
||||||
|
│ Template: Standard Consent - Template │
|
||||||
|
│ Template: Feedback Survey - Template │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Create a Form
|
||||||
|
|
||||||
|
### Using a Template
|
||||||
|
|
||||||
|
1. Click **Create Form**
|
||||||
|
2. Select **Use Template**
|
||||||
|
3. Choose template:
|
||||||
|
- Informed Consent
|
||||||
|
- Post-Session Survey
|
||||||
|
- Demographics
|
||||||
|
4. Customize as needed
|
||||||
|
|
||||||
|
### From Scratch
|
||||||
|
|
||||||
|
1. Click **Create Form**
|
||||||
|
2. Select **Blank Form**
|
||||||
|
3. Choose form type
|
||||||
|
4. Build fields manually
|
||||||
|
|
||||||
|
## Step 3: Form Builder
|
||||||
|
|
||||||
|
The form builder lets you create custom fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Form Builder: Post-Session Survey │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Form Settings │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Title: Post-Session Survey │ │
|
||||||
|
│ │ Type: Survey │ │
|
||||||
|
│ │ Active: ☑ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Fields │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. [Rating] How engaging was the robot? [✕] │ │
|
||||||
|
│ │ 2. [Text] What did you enjoy most? [✕] │ │
|
||||||
|
│ │ 3. [Multiple Choice] Robot personality? [✕] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [+ Add Field] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Preview │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ How engaging was the robot? │ │
|
||||||
|
│ │ ○ 1 ○ 2 ○ 3 ○ 4 ○ 5 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Save] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Field Types
|
||||||
|
|
||||||
|
### Text Field
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Text │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: Participant Age │
|
||||||
|
│ Required: ☑ │
|
||||||
|
│ Placeholder: e.g., 25 │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Participant Age * │ │
|
||||||
|
│ │ [e.g., 25 ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rating Scale
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Rating │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: How engaging was the robot? │
|
||||||
|
│ Required: ☑ │
|
||||||
|
│ Scale: 1 to [5] │
|
||||||
|
│ Low Label: Not at all engaging │
|
||||||
|
│ High Label: Very engaging │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ How engaging was the robot? * │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 1 2 3 4 5 │ │
|
||||||
|
│ │ ○ ○ ○ ○ ○ │ │
|
||||||
|
│ │ Not at all Very engaging │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Choice
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Multiple Choice │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: Did the robot respond appropriately? │
|
||||||
|
│ Required: ☑ │
|
||||||
|
│ Options: │
|
||||||
|
│ 1. Yes, always │
|
||||||
|
│ 2. Yes, most of the time │
|
||||||
|
│ 3. Sometimes │
|
||||||
|
│ 4. Rarely │
|
||||||
|
│ 5. No │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Did the robot respond appropriately? * │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ○ Yes, always │ │
|
||||||
|
│ │ ○ Yes, most of the time │ │
|
||||||
|
│ │ ○ Sometimes │ │
|
||||||
|
│ │ ○ Rarely │ │
|
||||||
|
│ │ ○ No │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yes/No
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Yes/No │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: Would you interact with this robot again? │
|
||||||
|
│ Required: ☐ │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Would you interact with this robot again? │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ○ Yes ○ No │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Area
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Text Area │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: What did you enjoy most about the interaction? │
|
||||||
|
│ Required: ☐ │
|
||||||
|
│ Rows: [4] │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ What did you enjoy most about the interaction? │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [ ] │ │
|
||||||
|
│ │ [ ] │ │
|
||||||
|
│ │ [ ] │ │
|
||||||
|
│ │ [ ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Date │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: Session Date │
|
||||||
|
│ Required: ☑ │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Session Date * │ │
|
||||||
|
│ │ [📅 Select date ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signature
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Field Type: Signature │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Label: Participant Signature │
|
||||||
|
│ Required: ☑ │
|
||||||
|
│ │
|
||||||
|
│ Preview: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Participant Signature * │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌───────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ [Sign here] │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ └───────────────────────────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Consent Forms
|
||||||
|
|
||||||
|
### Required Elements
|
||||||
|
|
||||||
|
For IRB compliance, consent forms must include:
|
||||||
|
|
||||||
|
- [ ] Study title and purpose
|
||||||
|
- [ ] Principal investigator
|
||||||
|
- [ ] Procedures description
|
||||||
|
- [ ] Risks and benefits
|
||||||
|
- [ ] Confidentiality statement
|
||||||
|
- [ ] Voluntary participation note
|
||||||
|
- [ ] Signature and date fields
|
||||||
|
|
||||||
|
### Consent Form Template
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Informed Consent",
|
||||||
|
"type": "consent",
|
||||||
|
"fields": [
|
||||||
|
{ "type": "text", "label": "Study Title", "required": true },
|
||||||
|
{ "type": "text", "label": "Principal Investigator", "required": true },
|
||||||
|
{ "type": "textarea", "label": "Purpose of the Study", "required": true },
|
||||||
|
{ "type": "textarea", "label": "Procedures", "required": true },
|
||||||
|
{ "type": "textarea", "label": "Risks and Benefits", "required": true },
|
||||||
|
{ "type": "textarea", "label": "Confidentiality", "required": true },
|
||||||
|
{ "type": "yes_no", "label": "I consent to participate", "required": true },
|
||||||
|
{ "type": "signature", "label": "Participant Signature", "required": true },
|
||||||
|
{ "type": "date", "label": "Date", "required": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Surveys
|
||||||
|
|
||||||
|
### Post-Session Survey Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Post-Session Questionnaire",
|
||||||
|
"type": "survey",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "rating",
|
||||||
|
"label": "How engaging was the robot?",
|
||||||
|
"settings": { "scale": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "rating",
|
||||||
|
"label": "How natural did the interaction feel?",
|
||||||
|
"settings": { "scale": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "multiple_choice",
|
||||||
|
"label": "Did the robot respond appropriately?",
|
||||||
|
"options": ["Always", "Usually", "Sometimes", "Rarely", "Never"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "What did you like most?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textarea",
|
||||||
|
"label": "What could be improved?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Questionnaire Example (Demographics)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Demographics",
|
||||||
|
"type": "questionnaire",
|
||||||
|
"fields": [
|
||||||
|
{ "type": "text", "label": "Age" },
|
||||||
|
{
|
||||||
|
"type": "multiple_choice",
|
||||||
|
"label": "Gender",
|
||||||
|
"options": ["Male", "Female", "Non-binary", "Prefer not to say"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "multiple_choice",
|
||||||
|
"label": "Experience with robots",
|
||||||
|
"options": ["None", "A little", "Moderate", "Extensive"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Form Versions
|
||||||
|
|
||||||
|
Forms support versioning for IRB compliance:
|
||||||
|
|
||||||
|
1. Create new version when modifying:
|
||||||
|
- Question text changes
|
||||||
|
- New fields added
|
||||||
|
- Required fields changed
|
||||||
|
|
||||||
|
2. Version history:
|
||||||
|
```
|
||||||
|
Version 1 (Current) - Active
|
||||||
|
Version 2 - Draft
|
||||||
|
Version 3 - Archived
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Track changes:
|
||||||
|
- Version number
|
||||||
|
- Change date
|
||||||
|
- Change description
|
||||||
|
|
||||||
|
## Step 8: Distributing Forms
|
||||||
|
|
||||||
|
### Automatic Distribution
|
||||||
|
|
||||||
|
Configure automatic form sending:
|
||||||
|
|
||||||
|
1. Open form settings
|
||||||
|
2. Enable **Auto-distribute**
|
||||||
|
3. Set trigger:
|
||||||
|
- Before trial (consent)
|
||||||
|
- After trial (survey)
|
||||||
|
4. Select participants
|
||||||
|
|
||||||
|
### Manual Distribution
|
||||||
|
|
||||||
|
Send forms manually:
|
||||||
|
|
||||||
|
1. Open form
|
||||||
|
2. Click **Distribute**
|
||||||
|
3. Select participants
|
||||||
|
4. Choose delivery method
|
||||||
|
|
||||||
|
### Participant Link
|
||||||
|
|
||||||
|
Generate shareable link:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://hristudio.example.com/forms/{formId}?participant={participantCode}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 9: Collecting Responses
|
||||||
|
|
||||||
|
### View Responses
|
||||||
|
|
||||||
|
1. Open form
|
||||||
|
2. Click **Responses** tab
|
||||||
|
3. View individual submissions
|
||||||
|
|
||||||
|
### Response Dashboard
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Form Responses: Post-Session Survey │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Total Responses: 15/20 (75%) │
|
||||||
|
│ │
|
||||||
|
│ Question: How engaging was the robot? │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 5 ████████████████████████████████████ 8 responses │ │
|
||||||
|
│ │ 4 ██████████████████ 5 responses │ │
|
||||||
|
│ │ 3 ████████ 2 responses │ │
|
||||||
|
│ │ 2 ████ 1 response │ │
|
||||||
|
│ │ 1 ████ 1 response │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Average: 4.2 / 5.0 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Responses
|
||||||
|
|
||||||
|
Download collected data:
|
||||||
|
|
||||||
|
| Format | Contents |
|
||||||
|
|--------|----------|
|
||||||
|
| CSV | Tabular data |
|
||||||
|
| JSON | Full response objects |
|
||||||
|
| PDF | Printed consent forms |
|
||||||
|
|
||||||
|
## Step 10: Form Templates
|
||||||
|
|
||||||
|
### Creating Templates
|
||||||
|
|
||||||
|
1. Create form with desired fields
|
||||||
|
2. Click **Save as Template**
|
||||||
|
3. Enter template name
|
||||||
|
4. Template is available for reuse
|
||||||
|
|
||||||
|
### Template Library
|
||||||
|
|
||||||
|
| Template | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| Standard Consent | Generic research consent |
|
||||||
|
| Child Consent | Studies with minors |
|
||||||
|
| Extended Consent | Complex procedures |
|
||||||
|
| Feedback Survey | Post-session feedback |
|
||||||
|
| NASA-TLX | Workload assessment |
|
||||||
|
| SUS | System usability |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Consent Forms
|
||||||
|
|
||||||
|
- [ ] Review with IRB before use
|
||||||
|
- [ ] Keep language simple
|
||||||
|
- [ ] Include all required elements
|
||||||
|
- [ ] Version control for changes
|
||||||
|
- [ ] Store signed forms securely
|
||||||
|
|
||||||
|
### Surveys
|
||||||
|
|
||||||
|
- [ ] Keep questions concise
|
||||||
|
- [ ] Use appropriate scales
|
||||||
|
- [ ] Test with pilot participants
|
||||||
|
- [ ] Randomize order when appropriate
|
||||||
|
- [ ] Include open-ended questions
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
- [ ] Export data regularly
|
||||||
|
- [ ] Backup responses
|
||||||
|
- [ ] Anonymize data for analysis
|
||||||
|
- [ ] Follow data retention policy
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Form Not Loading
|
||||||
|
|
||||||
|
- Check form is active
|
||||||
|
- Verify participant access
|
||||||
|
- Check network connection
|
||||||
|
|
||||||
|
### Response Not Saving
|
||||||
|
|
||||||
|
- Check required fields
|
||||||
|
- Verify session active
|
||||||
|
- Try again or refresh
|
||||||
|
|
||||||
|
### Participant Can't Access
|
||||||
|
|
||||||
|
- Verify participant code valid
|
||||||
|
- Check form is distributed
|
||||||
|
- Confirm study is active
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you've created your forms:
|
||||||
|
|
||||||
|
1. **[Running Trials](04-running-trials.md)** - Connect forms to trials
|
||||||
|
2. **[Data & Analysis](08-data-and-analysis.md)** - Analyze collected data
|
||||||
|
3. **[Your First Study](02-your-first-study.md)** - Set up your study
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Robot Integration](06-robot-integration.md) | **Next**: [Data & Analysis](08-data-and-analysis.md)
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# Tutorial 8: Data & Analysis
|
||||||
|
|
||||||
|
Learn how to collect, export, and analyze trial data from HRIStudio.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Understand data collection in HRIStudio
|
||||||
|
- Export trial data in various formats
|
||||||
|
- Analyze event logs
|
||||||
|
- Generate reports
|
||||||
|
|
||||||
|
## Data Collection Overview
|
||||||
|
|
||||||
|
HRIStudio automatically captures comprehensive data during trials:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Data Collection │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Trial Metadata │
|
||||||
|
│ ├── Start/End times │
|
||||||
|
│ ├── Duration │
|
||||||
|
│ ├── Participant info │
|
||||||
|
│ └── Experiment version │
|
||||||
|
│ │
|
||||||
|
│ Event Log (Timestamped) │
|
||||||
|
│ ├── Step changes │
|
||||||
|
│ ├── Action executions │
|
||||||
|
│ ├── Robot responses │
|
||||||
|
│ └── Wizard interventions │
|
||||||
|
│ │
|
||||||
|
│ Form Responses │
|
||||||
|
│ ├── Consent forms │
|
||||||
|
│ ├── Surveys │
|
||||||
|
│ └── Questionnaires │
|
||||||
|
│ │
|
||||||
|
│ Sensor Data │
|
||||||
|
│ ├── Joint positions │
|
||||||
|
│ ├── Touch events │
|
||||||
|
│ └── Audio/video (if enabled) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Accessing Trial Data
|
||||||
|
|
||||||
|
### From Trial List
|
||||||
|
|
||||||
|
1. Go to **Trials** tab
|
||||||
|
2. Find completed trial
|
||||||
|
3. Click **View Details**
|
||||||
|
|
||||||
|
### From Study Dashboard
|
||||||
|
|
||||||
|
1. Open your study
|
||||||
|
2. Go to **Data** tab
|
||||||
|
3. Select trial or view aggregate
|
||||||
|
|
||||||
|
## Step 2: Trial Event Log
|
||||||
|
|
||||||
|
Each trial generates a complete event log:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trialId": "trial_abc123",
|
||||||
|
"participantCode": "P001",
|
||||||
|
"experimentName": "Interactive Storyteller",
|
||||||
|
"startedAt": "2024-03-15T14:00:00Z",
|
||||||
|
"completedAt": "2024-03-15T14:05:23Z",
|
||||||
|
"duration": 323,
|
||||||
|
"status": "completed",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:00:00.123Z",
|
||||||
|
"type": "trial_started",
|
||||||
|
"stepId": null,
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:00:02.456Z",
|
||||||
|
"type": "step_changed",
|
||||||
|
"stepId": "step_1",
|
||||||
|
"stepName": "The Hook",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:00:03.789Z",
|
||||||
|
"type": "action_executed",
|
||||||
|
"actionName": "Say Text",
|
||||||
|
"parameters": { "text": "Hello!" },
|
||||||
|
"duration": 2300,
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:00:08.012Z",
|
||||||
|
"type": "action_executed",
|
||||||
|
"actionName": "Wave",
|
||||||
|
"duration": 1500,
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:02:30.123Z",
|
||||||
|
"type": "intervention",
|
||||||
|
"interventionType": "note",
|
||||||
|
"data": { "note": "Participant laughed" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:03:00.456Z",
|
||||||
|
"type": "wizard_response",
|
||||||
|
"variable": "last_response",
|
||||||
|
"selectedValue": "correct",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2024-03-15T14:05:23.789Z",
|
||||||
|
"type": "trial_completed",
|
||||||
|
"data": { "stepsCompleted": 6 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
| Event Type | Description | Data Captured |
|
||||||
|
|------------|-------------|---------------|
|
||||||
|
| `trial_started` | Trial began | Timestamp |
|
||||||
|
| `step_changed` | New step began | Step ID, name |
|
||||||
|
| `action_executed` | Robot action | Action details, duration |
|
||||||
|
| `action_completed` | Action finished | Duration, result |
|
||||||
|
| `action_failed` | Action failed | Error details |
|
||||||
|
| `wizard_response` | Wizard decision | Selected option |
|
||||||
|
| `intervention` | Wizard intervention | Type, note |
|
||||||
|
| `trial_paused` | Trial paused | Reason |
|
||||||
|
| `trial_resumed` | Trial resumed | Pause duration |
|
||||||
|
| `trial_completed` | Trial finished | Summary |
|
||||||
|
|
||||||
|
## Step 3: Exporting Data
|
||||||
|
|
||||||
|
### Export Single Trial
|
||||||
|
|
||||||
|
1. Open trial details
|
||||||
|
2. Click **Export**
|
||||||
|
3. Select format
|
||||||
|
|
||||||
|
### Export Study Data
|
||||||
|
|
||||||
|
1. Open study
|
||||||
|
2. Go to **Data** tab
|
||||||
|
3. Click **Export All**
|
||||||
|
4. Select options:
|
||||||
|
- Date range
|
||||||
|
- Trial status
|
||||||
|
- Include forms
|
||||||
|
|
||||||
|
### Export Formats
|
||||||
|
|
||||||
|
#### CSV Format
|
||||||
|
|
||||||
|
```csv
|
||||||
|
trial_id,participant,experiment,started_at,duration,status,steps_completed
|
||||||
|
trial_abc,P001,Interactive Storyteller,2024-03-15T14:00:00Z,323,completed,6
|
||||||
|
trial_def,P002,Interactive Storyteller,2024-03-15T14:20:00Z,298,completed,6
|
||||||
|
trial_ghi,P003,Interactive Storyteller,2024-03-15T14:40:00Z,0,failed,1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exportDate": "2024-03-15T15:00:00Z",
|
||||||
|
"studyName": "Robot Trust Study",
|
||||||
|
"trials": [...],
|
||||||
|
"forms": [...],
|
||||||
|
"metadata": {
|
||||||
|
"totalTrials": 20,
|
||||||
|
"completedTrials": 18,
|
||||||
|
"averageDuration": 312
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Event Log CSV
|
||||||
|
|
||||||
|
```csv
|
||||||
|
timestamp,event_type,step_name,action_name,parameters,duration,status
|
||||||
|
2024-03-15T14:00:00.123Z,trial_started,,,,,
|
||||||
|
2024-03-15T14:00:02.456Z,step_changed,The Hook,,,,
|
||||||
|
2024-03-15T14:00:03.789Z,action_executed,The Hook,Say Text,"{""text"":""Hello!""}",2300,completed
|
||||||
|
2024-03-15T14:00:08.012Z,action_executed,The Hook,Wave,,1500,completed
|
||||||
|
2024-03-15T14:02:30.123Z,intervention,The Narrative,Note,"{""note"":""Participant laughed""}",,,
|
||||||
|
2024-03-15T14:03:00.456Z,wizard_response,Comprehension Check,Correct,,,,
|
||||||
|
2024-03-15T14:05:23.789Z,trial_completed,,,,323,
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Data Dashboard
|
||||||
|
|
||||||
|
### Study Dashboard
|
||||||
|
|
||||||
|
View aggregate statistics:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Study Dashboard: Robot Trust Study │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Overview │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ 20 │ │ 18 │ │ 5m12s │ │ 2 │ │
|
||||||
|
│ │ Trials │ │ Complete│ │ Avg Time│ │ Failed │ │
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Completion Rate │
|
||||||
|
│ ████████████████████████████████████░░░░ 90% │
|
||||||
|
│ │
|
||||||
|
│ Timeline │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ P001 ████████████████████████████████ 5:23 │ │
|
||||||
|
│ │ P002 ██████████████████████████████ 5:02 │ │
|
||||||
|
│ │ P003 ██████████████████████████ 4:45 │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
| Metric | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Total Trials | Number of scheduled trials |
|
||||||
|
| Completed | Successfully completed trials |
|
||||||
|
| Average Duration | Mean trial time |
|
||||||
|
| Completion Rate | % of trials completed |
|
||||||
|
| Failed | Trials that failed |
|
||||||
|
| Average Steps | Mean steps per trial |
|
||||||
|
|
||||||
|
## Step 5: Analyzing Event Data
|
||||||
|
|
||||||
|
### Timing Analysis
|
||||||
|
|
||||||
|
Calculate action durations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open('trial_events.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Calculate action durations
|
||||||
|
for event in data['events']:
|
||||||
|
if event['type'] == 'action_executed':
|
||||||
|
duration = event.get('duration', 0)
|
||||||
|
print(f"{event['actionName']}: {duration/1000:.1f}s")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intervention Analysis
|
||||||
|
|
||||||
|
Track wizard interventions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Count interventions by type
|
||||||
|
interventions = [
|
||||||
|
e for e in data['events']
|
||||||
|
if e['type'] == 'intervention'
|
||||||
|
]
|
||||||
|
|
||||||
|
by_type = {}
|
||||||
|
for i in interventions:
|
||||||
|
itype = i['data'].get('type', 'unknown')
|
||||||
|
by_type[itype] = by_type.get(itype, 0) + 1
|
||||||
|
|
||||||
|
print(by_type)
|
||||||
|
# {'note': 15, 'pause': 3, 'alert': 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branch Selection Analysis
|
||||||
|
|
||||||
|
Analyze wizard decisions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get wizard responses
|
||||||
|
responses = [
|
||||||
|
e for e in data['events']
|
||||||
|
if e['type'] == 'wizard_response'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count by value
|
||||||
|
by_value = {}
|
||||||
|
for r in responses:
|
||||||
|
value = r.get('selectedValue', 'unknown')
|
||||||
|
by_value[value] = by_value.get(value, 0) + 1
|
||||||
|
|
||||||
|
print(by_value)
|
||||||
|
# {'correct': 12, 'incorrect': 6}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Form Data Analysis
|
||||||
|
|
||||||
|
### Response Aggregation
|
||||||
|
|
||||||
|
Aggregate survey responses:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Calculate average rating
|
||||||
|
ratings = [
|
||||||
|
r['responses']['engagement_rating']
|
||||||
|
for r in form_responses
|
||||||
|
]
|
||||||
|
|
||||||
|
avg_rating = sum(ratings) / len(ratings)
|
||||||
|
print(f"Average engagement: {avg_rating:.2f}/5")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Tabulation
|
||||||
|
|
||||||
|
Compare responses across conditions:
|
||||||
|
|
||||||
|
```
|
||||||
|
| Condition A | Condition B | Total
|
||||||
|
--------------------|------------|-------------|-------
|
||||||
|
Robot engaged | 4.2 | 4.5 | 4.35
|
||||||
|
Natural interaction | 3.8 | 4.1 | 3.95
|
||||||
|
Would use again | 78% | 85% | 81%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Data Visualization
|
||||||
|
|
||||||
|
### Trial Timeline
|
||||||
|
|
||||||
|
Visualize trial progression:
|
||||||
|
|
||||||
|
```
|
||||||
|
P001: ████████████████░░░░░░░░░░░░░░░░░ 5:23
|
||||||
|
P002: ███████████████░░░░░░░░░░░░░░░░░░ 4:58
|
||||||
|
P003: ██████████████████████████████░░░░ 6:02
|
||||||
|
P004: ████████████████░░░░░░░░░░░░░░░░░░ 5:15
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Distribution
|
||||||
|
|
||||||
|
```
|
||||||
|
Action Frequency
|
||||||
|
────────────────
|
||||||
|
Say Text ████████████████████ 45
|
||||||
|
Wave ████████████ 25
|
||||||
|
Turn Head ████████████ 25
|
||||||
|
Move Arm ████ 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branch Outcomes
|
||||||
|
|
||||||
|
```
|
||||||
|
Branch Selection
|
||||||
|
────────────────
|
||||||
|
Correct Response (A): ██████████████████████████ 67%
|
||||||
|
Incorrect Response (B): █████████████ 33%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8: Generating Reports
|
||||||
|
|
||||||
|
### Trial Summary Report
|
||||||
|
|
||||||
|
Generate PDF summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
TRIAL SUMMARY REPORT
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Study: Robot Trust Study
|
||||||
|
Participant: P001
|
||||||
|
Date: March 15, 2024
|
||||||
|
Experiment: Interactive Storyteller v1
|
||||||
|
|
||||||
|
EXECUTIVE SUMMARY
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Duration: 5 minutes 23 seconds
|
||||||
|
Status: Completed successfully
|
||||||
|
Steps Completed: 6/6
|
||||||
|
Interventions: 2
|
||||||
|
|
||||||
|
TIMELINE
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
14:00:00 Trial started
|
||||||
|
14:00:02 Step 1: The Hook
|
||||||
|
14:00:08 Step 2: The Narrative
|
||||||
|
14:02:30 Wizard note: "Participant engaged"
|
||||||
|
14:03:00 Step 3: Comprehension Check
|
||||||
|
14:03:28 Branch selected: Correct
|
||||||
|
14:03:30 Step 4a: Correct Response
|
||||||
|
14:05:23 Trial completed
|
||||||
|
|
||||||
|
METRICS
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Actions Executed: 12
|
||||||
|
Action Success Rate: 100%
|
||||||
|
Average Action Duration: 2.1s
|
||||||
|
Wizard Intervention Rate: 0.37/min
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
### Study Report
|
||||||
|
|
||||||
|
Aggregate across participants:
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
STUDY REPORT
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Study: Robot Trust Study
|
||||||
|
Date Range: March 1-15, 2024
|
||||||
|
Participants: 20
|
||||||
|
|
||||||
|
PARTICIPATION
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Enrolled: 20
|
||||||
|
Completed: 18 (90%)
|
||||||
|
Withdrew: 1 (5%)
|
||||||
|
Failed: 1 (5%)
|
||||||
|
|
||||||
|
TIMING
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Mean Duration: 5m 12s ± 28s
|
||||||
|
Min Duration: 4m 45s
|
||||||
|
Max Duration: 6m 02s
|
||||||
|
|
||||||
|
INTERVENTIONS
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Total Interventions: 34
|
||||||
|
Notes: 25 (73%)
|
||||||
|
Pauses: 7 (21%)
|
||||||
|
Alerts: 2 (6%)
|
||||||
|
|
||||||
|
BRANCH SELECTION
|
||||||
|
───────────────────────────────────────────────────────────
|
||||||
|
Branch A (Correct): 12 (67%)
|
||||||
|
Branch B (Incorrect): 6 (33%)
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 9: Data Privacy
|
||||||
|
|
||||||
|
### Anonymization
|
||||||
|
|
||||||
|
Remove identifying information:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Replace participant codes with anonymous IDs
|
||||||
|
participant_map = {
|
||||||
|
'P001': 'S001',
|
||||||
|
'P002': 'S002',
|
||||||
|
'P003': 'S003',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Settings
|
||||||
|
|
||||||
|
Configure export options:
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Include participant codes | Keep or anonymize |
|
||||||
|
| Include timestamps | Full or relative |
|
||||||
|
| Include notes | Include/exclude |
|
||||||
|
| Include form responses | Include/exclude |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Data Collection
|
||||||
|
|
||||||
|
- [ ] Enable all event logging
|
||||||
|
- [ ] Configure sensor data capture
|
||||||
|
- [ ] Set up automatic backups
|
||||||
|
- [ ] Test data export before study
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
- [ ] Export regularly (daily/weekly)
|
||||||
|
- [ ] Store in secure location
|
||||||
|
- [ ] Follow IRB data retention
|
||||||
|
- [ ] Backup critical data
|
||||||
|
|
||||||
|
### Data Analysis
|
||||||
|
|
||||||
|
- [ ] Document analysis methods
|
||||||
|
- [ ] Track protocol versions
|
||||||
|
- [ ] Note data quality issues
|
||||||
|
- [ ] Share data dictionary
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you understand data collection:
|
||||||
|
|
||||||
|
1. **[Your First Study](02-your-first-study.md)** - Apply data practices
|
||||||
|
2. **[Simulation Mode](09-simulation-mode.md)** - Test data collection
|
||||||
|
3. **[Running Trials](04-running-trials.md)** - Practice with data capture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Forms & Surveys](07-forms-and-surveys.md) | **Next**: [Simulation Mode](09-simulation-mode.md)
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
# Tutorial 9: Simulation Mode
|
||||||
|
|
||||||
|
Learn how to test HRIStudio experiments without a physical robot.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
- Enable simulation mode
|
||||||
|
- Use the mock robot server
|
||||||
|
- Test experiments end-to-end
|
||||||
|
- Practice trial execution
|
||||||
|
|
||||||
|
## Why Simulation Mode?
|
||||||
|
|
||||||
|
Simulation mode allows you to:
|
||||||
|
|
||||||
|
- **Test protocols** without a robot
|
||||||
|
- **Train wizards** before live sessions
|
||||||
|
- **Debug experiments** in development
|
||||||
|
- **Run pilots** without robot access
|
||||||
|
- **Develop** on any computer
|
||||||
|
|
||||||
|
## Understanding Simulation Options
|
||||||
|
|
||||||
|
HRIStudio offers two simulation approaches:
|
||||||
|
|
||||||
|
| Approach | Pros | Cons |
|
||||||
|
|----------|------|------|
|
||||||
|
| **Client-side** | No server needed, instant | Limited robot simulation |
|
||||||
|
| **Mock Server** | Full rosbridge protocol | Requires running server |
|
||||||
|
|
||||||
|
### Client-Side Simulation
|
||||||
|
|
||||||
|
Simulates robot locally in the browser:
|
||||||
|
- No network required
|
||||||
|
- Instant startup
|
||||||
|
- Basic action timing
|
||||||
|
- Fake sensor data
|
||||||
|
|
||||||
|
### Mock Server
|
||||||
|
|
||||||
|
Full WebSocket server simulating rosbridge:
|
||||||
|
- Complete protocol support
|
||||||
|
- Realistic timing
|
||||||
|
- Sensor data simulation
|
||||||
|
- Better for integration testing
|
||||||
|
|
||||||
|
## Step 1: Enable Client-Side Simulation
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. Create or edit `hristudio/.env.local`
|
||||||
|
2. Add:
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_SIMULATION_MODE=true
|
||||||
|
```
|
||||||
|
3. Restart the dev server:
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Enabled
|
||||||
|
|
||||||
|
Look for the simulation indicator in the UI:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Wizard Interface [🔵 SIMULATION MODE] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features Available
|
||||||
|
|
||||||
|
In simulation mode:
|
||||||
|
|
||||||
|
- ✅ All robot actions execute (simulated timing)
|
||||||
|
- ✅ Speech actions show estimated duration
|
||||||
|
- ✅ Movement actions track position
|
||||||
|
- ✅ Sensor data is simulated
|
||||||
|
- ✅ Trial execution works normally
|
||||||
|
- ❌ Real robot not controlled
|
||||||
|
- ❌ Physical interactions not possible
|
||||||
|
|
||||||
|
## Step 2: Start Mock Robot Server
|
||||||
|
|
||||||
|
For more complete testing, use the mock server:
|
||||||
|
|
||||||
|
### Option 1: Standalone Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hristudio/scripts/mock-robot
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server starts on `ws://localhost:9090`
|
||||||
|
|
||||||
|
### Option 2: Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nao6-hristudio-integration
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.mock.yml --profile mock up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Server Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check container
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Should show:
|
||||||
|
# CONTAINER ID IMAGE STATUS
|
||||||
|
# abc123def456 hristudio-mock-robot Up 2 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Connect to Mock Server
|
||||||
|
|
||||||
|
1. Go to the **NAO Test Page**: `/nao-test`
|
||||||
|
2. Ensure `NEXT_PUBLIC_SIMULATION_MODE` is NOT set (or set to false)
|
||||||
|
3. Click **Connect**
|
||||||
|
4. You should see:
|
||||||
|
```
|
||||||
|
Connected to rosbridge
|
||||||
|
Subscribed to: /joint_states, /bumper, /sonar/left, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Test Robot Actions
|
||||||
|
|
||||||
|
### From NAO Test Page
|
||||||
|
|
||||||
|
1. **Speech Test**
|
||||||
|
- Enter text: "Hello, this is a test"
|
||||||
|
- Click **Say**
|
||||||
|
- See simulated speech duration
|
||||||
|
|
||||||
|
2. **Movement Test**
|
||||||
|
- Set walk speed: 0.1 m/s
|
||||||
|
- Click **Walk Forward**
|
||||||
|
- Watch position update
|
||||||
|
|
||||||
|
3. **Head Control**
|
||||||
|
- Set yaw: 1.0, pitch: 0.0
|
||||||
|
- Click **Move Head**
|
||||||
|
- See joint angles update
|
||||||
|
|
||||||
|
### From Wizard Interface
|
||||||
|
|
||||||
|
1. Start a trial
|
||||||
|
2. Execute actions as normal
|
||||||
|
3. Actions are sent to mock server
|
||||||
|
4. Mock server responds with simulated data
|
||||||
|
|
||||||
|
## Step 5: Simulated Actions Reference
|
||||||
|
|
||||||
|
### Speech Actions
|
||||||
|
|
||||||
|
| Action | Simulation Behavior |
|
||||||
|
|--------|---------------------|
|
||||||
|
| `say_text` | Duration = 1.5s + 300ms × word_count |
|
||||||
|
| `say_with_emotion` | Duration = 1.5s + 300ms × word_count + emotion_overhead |
|
||||||
|
| `wave_goodbye` | Duration = 3.0s |
|
||||||
|
|
||||||
|
### Movement Actions
|
||||||
|
|
||||||
|
| Action | Simulation Behavior |
|
||||||
|
|--------|---------------------|
|
||||||
|
| `walk_forward` | Position updates over 500ms |
|
||||||
|
| `walk_backward` | Position updates over 500ms |
|
||||||
|
| `turn_left` | Angle decreases over 500ms |
|
||||||
|
| `turn_right` | Angle increases over 500ms |
|
||||||
|
| `stop` | Velocity set to 0 |
|
||||||
|
|
||||||
|
### Sensor Simulation
|
||||||
|
|
||||||
|
| Sensor | Simulated Value |
|
||||||
|
|--------|-----------------|
|
||||||
|
| Battery | 85% ± 2% variation |
|
||||||
|
| Joint States | Random positions ±0.1 rad |
|
||||||
|
| Bumper | False (no contact) |
|
||||||
|
| Sonar | 0.5-1.0m (random) |
|
||||||
|
| Touch | False (no touch) |
|
||||||
|
|
||||||
|
## Step 6: Testing Experiment Protocols
|
||||||
|
|
||||||
|
### Full Protocol Test
|
||||||
|
|
||||||
|
1. Enable simulation mode
|
||||||
|
2. Create or open experiment
|
||||||
|
3. Schedule trial
|
||||||
|
4. Start trial in wizard interface
|
||||||
|
5. Execute through all steps
|
||||||
|
6. Verify timing and flow
|
||||||
|
|
||||||
|
### Test Checklist
|
||||||
|
|
||||||
|
- [ ] All steps execute in order
|
||||||
|
- [ ] Branching decisions work
|
||||||
|
- [ ] Timing estimates are accurate
|
||||||
|
- [ ] Event log captures everything
|
||||||
|
- [ ] No errors or warnings
|
||||||
|
- [ ] Trial completes successfully
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable verbose logging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In browser console, run:
|
||||||
|
localStorage.setItem('debug', 'true')
|
||||||
|
|
||||||
|
# Refresh page
|
||||||
|
# Now see detailed action logs in console
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Training Wizards
|
||||||
|
|
||||||
|
Simulation mode is perfect for training:
|
||||||
|
|
||||||
|
### Training Scenario 1: Basic Operation
|
||||||
|
|
||||||
|
1. Enable simulation mode
|
||||||
|
2. Load simple experiment
|
||||||
|
3. Practice:
|
||||||
|
- Starting/pausing trials
|
||||||
|
- Executing quick actions
|
||||||
|
- Adding notes
|
||||||
|
|
||||||
|
### Training Scenario 2: Decision Making
|
||||||
|
|
||||||
|
1. Load branching experiment
|
||||||
|
2. Practice:
|
||||||
|
- Observing participant cues
|
||||||
|
- Selecting appropriate branches
|
||||||
|
- Documenting decisions
|
||||||
|
|
||||||
|
### Training Scenario 3: Handling Issues
|
||||||
|
|
||||||
|
1. Practice:
|
||||||
|
- Pausing for breaks
|
||||||
|
- Responding to alerts
|
||||||
|
- Stopping trials early
|
||||||
|
|
||||||
|
## Step 8: Development Workflow
|
||||||
|
|
||||||
|
### TDD with Simulation
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Development Cycle │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. Design experiment in UI │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 2. Enable simulation mode │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 3. Run test trial │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 4. Review event log │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 5. Fix issues found │
|
||||||
|
│ │ │
|
||||||
|
│ └────────────┐ │
|
||||||
|
│ │ │
|
||||||
|
│ └ (repeat) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Checklist
|
||||||
|
|
||||||
|
Before running real trials:
|
||||||
|
|
||||||
|
- [ ] Experiment works in simulation
|
||||||
|
- [ ] All actions execute correctly
|
||||||
|
- [ ] Timing is acceptable
|
||||||
|
- [ ] Branching works as expected
|
||||||
|
- [ ] Wizard notes function properly
|
||||||
|
- [ ] Data exports correctly
|
||||||
|
|
||||||
|
## Step 9: Transitioning to Real Robot
|
||||||
|
|
||||||
|
When ready to test with real robot:
|
||||||
|
|
||||||
|
### Step 1: Disable Simulation
|
||||||
|
|
||||||
|
Remove or set to false:
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_SIMULATION_MODE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Connect Robot
|
||||||
|
|
||||||
|
1. Start Docker services
|
||||||
|
2. Verify robot connection
|
||||||
|
3. Test with NAO Test Page
|
||||||
|
|
||||||
|
### Step 3: Run Comparison Trial
|
||||||
|
|
||||||
|
1. Run same experiment on real robot
|
||||||
|
2. Compare timing and behavior
|
||||||
|
3. Adjust parameters as needed
|
||||||
|
|
||||||
|
### Step 4: Document Differences
|
||||||
|
|
||||||
|
Note any protocol adjustments needed:
|
||||||
|
- Timing differences
|
||||||
|
- Action parameter changes
|
||||||
|
- Branch criteria updates
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Simulation Actions Not Working
|
||||||
|
|
||||||
|
1. Check `NEXT_PUBLIC_SIMULATION_MODE=true` is set
|
||||||
|
2. Verify no errors in browser console
|
||||||
|
3. Try refreshing the page
|
||||||
|
|
||||||
|
### Mock Server Connection Failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if server is running
|
||||||
|
docker ps | grep mock
|
||||||
|
|
||||||
|
# Check server logs
|
||||||
|
docker compose logs mock_robot
|
||||||
|
|
||||||
|
# Restart if needed
|
||||||
|
docker compose restart mock_robot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions Execute But Nothing Happens
|
||||||
|
|
||||||
|
1. Check WebSocket URL is correct
|
||||||
|
2. Verify port 9090 is not blocked
|
||||||
|
3. Try client-side simulation instead
|
||||||
|
|
||||||
|
## Comparison: Simulation vs Real
|
||||||
|
|
||||||
|
| Aspect | Simulation | Real Robot |
|
||||||
|
|--------|------------|------------|
|
||||||
|
| Setup time | 1 min | 30+ min |
|
||||||
|
| Availability | Always | Requires robot |
|
||||||
|
| Cost | Free | Robot access needed |
|
||||||
|
| Timing accuracy | Estimated | Actual |
|
||||||
|
| Physical interaction | ✗ | ✓ |
|
||||||
|
| Sensor accuracy | Fake | Real |
|
||||||
|
| Network dependent | No | Yes |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### When to Use Simulation
|
||||||
|
|
||||||
|
- During experiment design
|
||||||
|
- While robot unavailable
|
||||||
|
- For wizard training
|
||||||
|
- For debugging protocols
|
||||||
|
- For quick iteration
|
||||||
|
|
||||||
|
### When to Use Real Robot
|
||||||
|
|
||||||
|
- Final protocol validation
|
||||||
|
- Timing accuracy critical
|
||||||
|
- Physical interaction matters
|
||||||
|
- Sensor data needed
|
||||||
|
- Pre-study pilot
|
||||||
|
|
||||||
|
### Transition Checklist
|
||||||
|
|
||||||
|
Before real trials:
|
||||||
|
- [ ] Protocol tested in simulation
|
||||||
|
- [ ] Timing verified
|
||||||
|
- [ ] Actions calibrated
|
||||||
|
- [ ] Wizard team trained
|
||||||
|
- [ ] Backup plan ready
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you've mastered simulation:
|
||||||
|
|
||||||
|
1. **[Robot Integration](06-robot-integration.md)** - Connect real robot
|
||||||
|
2. **[Running Trials](04-running-trials.md)** - Execute live trials
|
||||||
|
3. **[Your First Study](02-your-first-study.md)** - Run complete study
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Previous**: [Data & Analysis](08-data-and-analysis.md) | **Back**: [Tutorials Overview](../tutorials/README.md)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# HRIStudio Tutorials
|
||||||
|
|
||||||
|
Welcome to the HRIStudio tutorials! These guides will help you get up and running with the platform for your HRI research.
|
||||||
|
|
||||||
|
## Tutorial Overview
|
||||||
|
|
||||||
|
| Tutorial | Description | Time |
|
||||||
|
|----------|-------------|------|
|
||||||
|
| **[Getting Started](tutorials/01-getting-started.md)** | Installation, setup, and first login | 10 min |
|
||||||
|
| **[Your First Study](tutorials/02-your-first-study.md)** | Creating a study and adding team members | 15 min |
|
||||||
|
| **[Designing Experiments](tutorials/03-designing-experiments.md)** | Building experiment protocols with blocks | 25 min |
|
||||||
|
| **[Running Trials](tutorials/04-running-trials.md)** | Executing trials and managing participants | 20 min |
|
||||||
|
| **[Wizard Interface](tutorials/05-wizard-interface.md)** | Real-time trial control and monitoring | 15 min |
|
||||||
|
| **[Robot Integration](tutorials/06-robot-integration.md)** | Connecting NAO6 and other robots | 20 min |
|
||||||
|
| **[Forms & Surveys](tutorials/07-forms-and-surveys.md)** | Creating consent forms and questionnaires | 15 min |
|
||||||
|
| **[Data & Analysis](tutorials/08-data-and-analysis.md)** | Collecting and exporting trial data | 15 min |
|
||||||
|
| **[Simulation Mode](tutorials/09-simulation-mode.md)** | Testing without a physical robot | 10 min |
|
||||||
|
|
||||||
|
## Quick Navigation
|
||||||
|
|
||||||
|
### For Researchers
|
||||||
|
1. [Getting Started](tutorials/01-getting-started.md) - Set up your environment
|
||||||
|
2. [Your First Study](tutorials/02-your-first-study.md) - Create your study
|
||||||
|
3. [Designing Experiments](tutorials/03-designing-experiments.md) - Build your protocol
|
||||||
|
4. [Running Trials](tutorials/04-running-trials.md) - Execute your study
|
||||||
|
5. [Data & Analysis](tutorials/08-data-and-analysis.md) - Analyze results
|
||||||
|
|
||||||
|
### For Wizards
|
||||||
|
1. [Getting Started](tutorials/01-getting-started.md) - Basic setup
|
||||||
|
2. [Wizard Interface](tutorials/05-wizard-interface.md) - Control trials
|
||||||
|
3. [Robot Integration](tutorials/06-robot-integration.md) - Connect to robot
|
||||||
|
|
||||||
|
### For Administrators
|
||||||
|
1. [Getting Started](tutorials/01-getting-started.md) - Full setup
|
||||||
|
2. [Robot Integration](tutorials/06-robot-integration.md) - Configure robots
|
||||||
|
3. [Forms & Surveys](tutorials/07-forms-and-surveys.md) - Manage templates
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Basic HRI Experiment
|
||||||
|
```
|
||||||
|
Create Study → Design Experiment → Add Participants → Run Trials → Collect Data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wizard-of-Oz Study
|
||||||
|
```
|
||||||
|
Create Study → Design Experiment with Wizard Blocks → Configure Robot →
|
||||||
|
Add Wizards → Run Trials with Live Control → Collect Data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pilot Testing
|
||||||
|
```
|
||||||
|
Create Study → Design Experiment → Enable Simulation Mode → Run Test Trials →
|
||||||
|
Refine Protocol → Connect Real Robot → Run Study
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **For local development**: Bun, Docker, PostgreSQL
|
||||||
|
- **For robot studies**: NAO6 robot or compatible robot
|
||||||
|
- **For cloud deployment**: Vercel, Cloudflare R2, PostgreSQL database
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- Check the [Quick Reference](../quick-reference.md) for common commands
|
||||||
|
- Review the [Implementation Guide](../implementation-guide.md) for technical details
|
||||||
|
- Visit the [NAO6 Integration](../nao6-quick-reference.md) for robot-specific help
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next**: [Getting Started](tutorials/01-getting-started.md)
|
||||||
@@ -9,4 +9,5 @@ export default {
|
|||||||
url: env.DATABASE_URL,
|
url: env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
tablesFilter: ["hs_*"],
|
tablesFilter: ["hs_*"],
|
||||||
|
out: "./migrations",
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1774137504617,
|
|
||||||
"tag": "0000_old_tattoo",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
CREATE TYPE "public"."block_category" AS ENUM('wizard', 'robot', 'control', 'sensor', 'logic', 'event');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."block_shape" AS ENUM('action', 'control', 'value', 'boolean', 'hat', 'cap');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."communication_protocol" AS ENUM('rest', 'ros2', 'custom');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."experiment_status" AS ENUM('draft', 'testing', 'ready', 'deprecated');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."export_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."form_field_type" AS ENUM('text', 'textarea', 'multiple_choice', 'checkbox', 'rating', 'yes_no', 'date', 'signature');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."form_response_status" AS ENUM('pending', 'completed', 'rejected');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."form_type" AS ENUM('consent', 'survey', 'questionnaire');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."media_type" AS ENUM('video', 'audio', 'image');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."plugin_status" AS ENUM('active', 'deprecated', 'disabled');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."step_type" AS ENUM('wizard', 'robot', 'parallel', 'conditional');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."study_member_role" AS ENUM('owner', 'researcher', 'wizard', 'observer');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."study_status" AS ENUM('draft', 'active', 'completed', 'archived');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."system_role" AS ENUM('administrator', 'researcher', 'wizard', 'observer');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."trial_status" AS ENUM('scheduled', 'in_progress', 'completed', 'aborted', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."trust_level" AS ENUM('official', 'verified', 'community');--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"provider_id" varchar(255) NOT NULL,
|
||||||
|
"account_id" varchar(255) NOT NULL,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token" text,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"scope" varchar(255),
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_account_provider_id_account_id_unique" UNIQUE("provider_id","account_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_action" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"step_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"type" varchar(100) NOT NULL,
|
||||||
|
"order_index" integer NOT NULL,
|
||||||
|
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"validation_schema" jsonb,
|
||||||
|
"timeout" integer,
|
||||||
|
"retry_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"source_kind" varchar(20),
|
||||||
|
"plugin_id" varchar(255),
|
||||||
|
"plugin_version" varchar(50),
|
||||||
|
"robot_id" varchar(255),
|
||||||
|
"base_action_id" varchar(255),
|
||||||
|
"category" varchar(50),
|
||||||
|
"transport" varchar(20),
|
||||||
|
"ros2_config" jsonb,
|
||||||
|
"rest_config" jsonb,
|
||||||
|
"retryable" boolean,
|
||||||
|
"parameter_schema_raw" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_action_step_id_order_index_unique" UNIQUE("step_id","order_index")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_activity_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid,
|
||||||
|
"user_id" text,
|
||||||
|
"action" varchar(100) NOT NULL,
|
||||||
|
"resource_type" varchar(50),
|
||||||
|
"resource_id" uuid,
|
||||||
|
"description" text,
|
||||||
|
"ip_address" "inet",
|
||||||
|
"user_agent" text,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_annotation" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"annotator_id" text NOT NULL,
|
||||||
|
"timestamp_start" timestamp with time zone NOT NULL,
|
||||||
|
"timestamp_end" timestamp with time zone,
|
||||||
|
"category" varchar(100),
|
||||||
|
"label" varchar(100),
|
||||||
|
"description" text,
|
||||||
|
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_attachment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"resource_type" varchar(50) NOT NULL,
|
||||||
|
"resource_id" uuid NOT NULL,
|
||||||
|
"file_name" varchar(255) NOT NULL,
|
||||||
|
"file_size" bigint NOT NULL,
|
||||||
|
"file_path" text NOT NULL,
|
||||||
|
"content_type" varchar(100),
|
||||||
|
"description" text,
|
||||||
|
"uploaded_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"action" varchar(100) NOT NULL,
|
||||||
|
"resource_type" varchar(50),
|
||||||
|
"resource_id" uuid,
|
||||||
|
"changes" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"ip_address" "inet",
|
||||||
|
"user_agent" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_block_registry" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"block_type" varchar(100) NOT NULL,
|
||||||
|
"plugin_id" uuid,
|
||||||
|
"shape" "block_shape" NOT NULL,
|
||||||
|
"category" "block_category" NOT NULL,
|
||||||
|
"display_name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"icon" varchar(100),
|
||||||
|
"color" varchar(50),
|
||||||
|
"config" jsonb NOT NULL,
|
||||||
|
"parameter_schema" jsonb NOT NULL,
|
||||||
|
"execution_handler" varchar(100),
|
||||||
|
"timeout" integer,
|
||||||
|
"retry_policy" jsonb,
|
||||||
|
"requires_connection" boolean DEFAULT false,
|
||||||
|
"preview_mode" boolean DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_block_registry_block_type_plugin_id_unique" UNIQUE("block_type","plugin_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_comment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"parent_id" uuid,
|
||||||
|
"resource_type" varchar(50) NOT NULL,
|
||||||
|
"resource_id" uuid NOT NULL,
|
||||||
|
"author_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_consent_form" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"title" varchar(255) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"storage_path" text,
|
||||||
|
CONSTRAINT "hs_consent_form_study_id_version_unique" UNIQUE("study_id","version")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_experiment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"robot_id" uuid,
|
||||||
|
"status" "experiment_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"estimated_duration" integer,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"visual_design" jsonb,
|
||||||
|
"execution_graph" jsonb,
|
||||||
|
"plugin_dependencies" text[],
|
||||||
|
"integrity_hash" varchar(128),
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "hs_experiment_study_id_name_version_unique" UNIQUE("study_id","name","version")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_export_job" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"requested_by" text NOT NULL,
|
||||||
|
"export_type" varchar(50) NOT NULL,
|
||||||
|
"format" varchar(20) NOT NULL,
|
||||||
|
"filters" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"status" "export_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"storage_path" text,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"error_message" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_form_response" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"form_id" uuid NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"responses" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"status" "form_response_status" DEFAULT 'pending',
|
||||||
|
"signature_data" text,
|
||||||
|
"signed_at" timestamp with time zone,
|
||||||
|
"ip_address" "inet",
|
||||||
|
"submitted_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_form_response_form_id_participant_id_unique" UNIQUE("form_id","participant_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_form" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"type" "form_type" NOT NULL,
|
||||||
|
"title" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"is_template" boolean DEFAULT false NOT NULL,
|
||||||
|
"template_name" varchar(100),
|
||||||
|
"fields" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_form_study_id_version_unique" UNIQUE("study_id","version")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_media_capture" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"media_type" "media_type",
|
||||||
|
"storage_path" text NOT NULL,
|
||||||
|
"file_size" bigint,
|
||||||
|
"duration" integer,
|
||||||
|
"format" varchar(20),
|
||||||
|
"resolution" varchar(20),
|
||||||
|
"start_timestamp" timestamp with time zone,
|
||||||
|
"end_timestamp" timestamp with time zone,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_participant_consent" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"consent_form_id" uuid NOT NULL,
|
||||||
|
"signed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"signature_data" text,
|
||||||
|
"ip_address" "inet",
|
||||||
|
"storage_path" text,
|
||||||
|
CONSTRAINT "hs_participant_consent_participant_id_consent_form_id_unique" UNIQUE("participant_id","consent_form_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_participant_document" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"type" varchar(100),
|
||||||
|
"storage_path" text NOT NULL,
|
||||||
|
"file_size" integer,
|
||||||
|
"uploaded_by" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_participant" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"participant_code" varchar(50) NOT NULL,
|
||||||
|
"email" varchar(255),
|
||||||
|
"name" varchar(255),
|
||||||
|
"demographics" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"consent_given" boolean DEFAULT false NOT NULL,
|
||||||
|
"consent_date" timestamp with time zone,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_participant_study_id_participant_code_unique" UNIQUE("study_id","participant_code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_permission" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"resource" varchar(50) NOT NULL,
|
||||||
|
"action" varchar(50) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_permission_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_plugin_repository" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"trust_level" "trust_level" DEFAULT 'community' NOT NULL,
|
||||||
|
"is_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"is_official" boolean DEFAULT false NOT NULL,
|
||||||
|
"last_sync_at" timestamp with time zone,
|
||||||
|
"sync_status" varchar(50) DEFAULT 'pending',
|
||||||
|
"sync_error" text,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
CONSTRAINT "hs_plugin_repository_url_unique" UNIQUE("url")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_plugin" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"robot_id" uuid,
|
||||||
|
"identifier" varchar(100) NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"version" varchar(50) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"author" varchar(255),
|
||||||
|
"repository_url" text,
|
||||||
|
"trust_level" "trust_level",
|
||||||
|
"status" "plugin_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"configuration_schema" jsonb,
|
||||||
|
"action_definitions" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
CONSTRAINT "hs_plugin_identifier_unique" UNIQUE("identifier"),
|
||||||
|
CONSTRAINT "hs_plugin_name_version_unique" UNIQUE("name","version")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_robot_plugin" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"version" varchar(50) NOT NULL,
|
||||||
|
"manufacturer" varchar(255),
|
||||||
|
"description" text,
|
||||||
|
"robot_id" uuid,
|
||||||
|
"communication_protocol" "communication_protocol",
|
||||||
|
"status" "plugin_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"config_schema" jsonb,
|
||||||
|
"capabilities" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"trust_level" "trust_level" DEFAULT 'community' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_robot" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"manufacturer" varchar(255),
|
||||||
|
"model" varchar(255),
|
||||||
|
"description" text,
|
||||||
|
"capabilities" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"communication_protocol" "communication_protocol",
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_role_permission" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"role" "system_role" NOT NULL,
|
||||||
|
"permission_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "hs_role_permission_role_permission_id_unique" UNIQUE("role","permission_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_sensor_data" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"sensor_type" varchar(50) NOT NULL,
|
||||||
|
"timestamp" timestamp with time zone NOT NULL,
|
||||||
|
"data" jsonb NOT NULL,
|
||||||
|
"robot_state" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"token" varchar(255) NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_shared_resource" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"resource_type" varchar(50) NOT NULL,
|
||||||
|
"resource_id" uuid NOT NULL,
|
||||||
|
"shared_by" text NOT NULL,
|
||||||
|
"share_token" varchar(255),
|
||||||
|
"permissions" jsonb DEFAULT '["read"]'::jsonb,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"access_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_shared_resource_share_token_unique" UNIQUE("share_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_step" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"experiment_id" uuid NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"type" "step_type" NOT NULL,
|
||||||
|
"order_index" integer NOT NULL,
|
||||||
|
"duration_estimate" integer,
|
||||||
|
"required" boolean DEFAULT true NOT NULL,
|
||||||
|
"conditions" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_step_experiment_id_order_index_unique" UNIQUE("experiment_id","order_index")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"institution" varchar(255),
|
||||||
|
"irb_protocol" varchar(100),
|
||||||
|
"status" "study_status" DEFAULT 'draft' NOT NULL,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_member" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role" "study_member_role" NOT NULL,
|
||||||
|
"permissions" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"invited_by" text,
|
||||||
|
CONSTRAINT "hs_study_member_study_id_user_id_unique" UNIQUE("study_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_study_plugin" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"study_id" uuid NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"configuration" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"installed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"installed_by" text NOT NULL,
|
||||||
|
CONSTRAINT "hs_study_plugin_study_id_plugin_id_unique" UNIQUE("study_id","plugin_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_system_setting" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"key" varchar(100) NOT NULL,
|
||||||
|
"value" jsonb NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"updated_by" text,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_system_setting_key_unique" UNIQUE("key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_trial_event" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"event_type" varchar(50) NOT NULL,
|
||||||
|
"action_id" uuid,
|
||||||
|
"timestamp" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"data" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_by" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_trial" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"experiment_id" uuid NOT NULL,
|
||||||
|
"participant_id" uuid,
|
||||||
|
"wizard_id" text,
|
||||||
|
"session_number" integer DEFAULT 1 NOT NULL,
|
||||||
|
"status" "trial_status" DEFAULT 'scheduled' NOT NULL,
|
||||||
|
"scheduled_at" timestamp with time zone,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"duration" integer,
|
||||||
|
"notes" text,
|
||||||
|
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_user_system_role" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role" "system_role" NOT NULL,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"granted_by" text,
|
||||||
|
CONSTRAINT "hs_user_system_role_user_id_role_unique" UNIQUE("user_id","role")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255),
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"password" varchar(255),
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "hs_user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_verification_token" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" varchar(255) NOT NULL,
|
||||||
|
"value" varchar(255) NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_verification_token_value_unique" UNIQUE("value")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_wizard_intervention" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"wizard_id" text NOT NULL,
|
||||||
|
"intervention_type" varchar(100) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"timestamp" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"reason" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hs_ws_connection" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"trial_id" uuid NOT NULL,
|
||||||
|
"client_id" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"connected_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "hs_ws_connection_client_id_unique" UNIQUE("client_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_account" ADD CONSTRAINT "hs_account_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_action" ADD CONSTRAINT "hs_action_step_id_hs_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."hs_step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_activity_log" ADD CONSTRAINT "hs_activity_log_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_activity_log" ADD CONSTRAINT "hs_activity_log_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_annotation" ADD CONSTRAINT "hs_annotation_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_annotation" ADD CONSTRAINT "hs_annotation_annotator_id_hs_user_id_fk" FOREIGN KEY ("annotator_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_attachment" ADD CONSTRAINT "hs_attachment_uploaded_by_hs_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_audit_log" ADD CONSTRAINT "hs_audit_log_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_block_registry" ADD CONSTRAINT "hs_block_registry_plugin_id_hs_robot_plugin_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."hs_robot_plugin"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_comment" ADD CONSTRAINT "hs_comment_author_id_hs_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_consent_form" ADD CONSTRAINT "hs_consent_form_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_consent_form" ADD CONSTRAINT "hs_consent_form_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_export_job" ADD CONSTRAINT "hs_export_job_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_export_job" ADD CONSTRAINT "hs_export_job_requested_by_hs_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_form_response" ADD CONSTRAINT "hs_form_response_form_id_hs_form_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."hs_form"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_form_response" ADD CONSTRAINT "hs_form_response_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_form" ADD CONSTRAINT "hs_form_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_form" ADD CONSTRAINT "hs_form_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_media_capture" ADD CONSTRAINT "hs_media_capture_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant_consent" ADD CONSTRAINT "hs_participant_consent_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant_consent" ADD CONSTRAINT "hs_participant_consent_consent_form_id_hs_consent_form_id_fk" FOREIGN KEY ("consent_form_id") REFERENCES "public"."hs_consent_form"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant_document" ADD CONSTRAINT "hs_participant_document_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant_document" ADD CONSTRAINT "hs_participant_document_uploaded_by_hs_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_participant" ADD CONSTRAINT "hs_participant_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_plugin_repository" ADD CONSTRAINT "hs_plugin_repository_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_plugin" ADD CONSTRAINT "hs_plugin_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_robot_plugin" ADD CONSTRAINT "hs_robot_plugin_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_role_permission" ADD CONSTRAINT "hs_role_permission_permission_id_hs_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."hs_permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_sensor_data" ADD CONSTRAINT "hs_sensor_data_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_session" ADD CONSTRAINT "hs_session_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_shared_resource" ADD CONSTRAINT "hs_shared_resource_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_shared_resource" ADD CONSTRAINT "hs_shared_resource_shared_by_hs_user_id_fk" FOREIGN KEY ("shared_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_step" ADD CONSTRAINT "hs_step_experiment_id_hs_experiment_id_fk" FOREIGN KEY ("experiment_id") REFERENCES "public"."hs_experiment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study" ADD CONSTRAINT "hs_study_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_invited_by_hs_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_plugin_id_hs_plugin_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."hs_plugin"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_installed_by_hs_user_id_fk" FOREIGN KEY ("installed_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_system_setting" ADD CONSTRAINT "hs_system_setting_updated_by_hs_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_action_id_hs_action_id_fk" FOREIGN KEY ("action_id") REFERENCES "public"."hs_action"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_experiment_id_hs_experiment_id_fk" FOREIGN KEY ("experiment_id") REFERENCES "public"."hs_experiment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_wizard_id_hs_user_id_fk" FOREIGN KEY ("wizard_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_user_system_role" ADD CONSTRAINT "hs_user_system_role_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_user_system_role" ADD CONSTRAINT "hs_user_system_role_granted_by_hs_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_wizard_intervention" ADD CONSTRAINT "hs_wizard_intervention_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_wizard_intervention" ADD CONSTRAINT "hs_wizard_intervention_wizard_id_hs_user_id_fk" FOREIGN KEY ("wizard_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hs_ws_connection" ADD CONSTRAINT "hs_ws_connection_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_user_id_idx" ON "hs_account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_logs_study_created_idx" ON "hs_activity_log" USING btree ("study_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_logs_created_idx" ON "hs_audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "block_registry_category_idx" ON "hs_block_registry" USING btree ("category");--> statement-breakpoint
|
||||||
|
CREATE INDEX "experiment_visual_design_idx" ON "hs_experiment" USING gin ("visual_design");--> statement-breakpoint
|
||||||
|
CREATE INDEX "participant_document_participant_idx" ON "hs_participant_document" USING btree ("participant_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sensor_data_trial_timestamp_idx" ON "hs_sensor_data" USING btree ("trial_id","timestamp");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_user_id_idx" ON "hs_session" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "trial_events_trial_timestamp_idx" ON "hs_trial_event" USING btree ("trial_id","timestamp");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_token_identifier_idx" ON "hs_verification_token" USING btree ("identifier");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_token_value_idx" ON "hs_verification_token" USING btree ("value");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration 0001: Minimal Seed Data
|
||||||
|
-- HRIStudio - Only essential data needed for auth
|
||||||
|
|
||||||
|
-- ======================
|
||||||
|
-- USERS & AUTH
|
||||||
|
-- ======================
|
||||||
|
|
||||||
|
-- Users (using valid UUID v4 format)
|
||||||
|
INSERT INTO "hs_user" ("id", "name", "email", "email_verified", "image", "created_at", "updated_at")
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-4111-8111-111111111111', 'Sean O''Connor', 'sean@soconnor.dev', true, 'https://www.gravatar.com/avatar/4b20f4a15f9a0e0f5e5e5a0f5e5e5a0f?d=identicon', NOW(), NOW()),
|
||||||
|
('22222222-2222-4222-8222-222222222222', 'Dr. Felipe Perrone', 'felipe.perrone@bucknell.edu', true, 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe', NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Accounts
|
||||||
|
INSERT INTO "hs_account" ("id", "user_id", "provider_id", "account_id", "password", "created_at", "updated_at")
|
||||||
|
VALUES
|
||||||
|
('aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', '11111111-1111-4111-8111-111111111111', 'credential', '11111111-1111-4111-8111-111111111111', '$2b$12$50kgpkp.qZrZXCWjHuVSHOZBjAQUrX50VdtWc6WBj27HnzUYFwwPm', NOW(), NOW()),
|
||||||
|
('bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb', '22222222-2222-4222-8222-222222222222', 'credential', '22222222-2222-4222-8222-222222222222', '$2b$12$50kgpkp.qZrZXCWjHuVSHOZBjAQUrX50VdtWc6WBj27HnzUYFwwPm', NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- System Roles
|
||||||
|
INSERT INTO "hs_user_system_role" ("id", "user_id", "role", "granted_at", "granted_by")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), '11111111-1111-4111-8111-111111111111', 'administrator', NOW(), '11111111-1111-4111-8111-111111111111'),
|
||||||
|
(gen_random_uuid(), '22222222-2222-4222-8222-222222222222', 'researcher', NOW(), '11111111-1111-4111-8111-111111111111')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Minimal seed migration complete';
|
||||||
|
RAISE NOTICE 'Admin: sean@soconnor.dev / password123';
|
||||||
|
RAISE NOTICE 'Use bun db:seed for full demo data';
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 0,
|
||||||
|
"tag": "0000_init_schema",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1,
|
||||||
|
"tag": "0001_seed_data",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+11
-7
@@ -5,22 +5,26 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "bun db:push && bun scripts/seed-dev.ts",
|
"db:seed": "bun db:push && bun scripts/seed-dev.ts",
|
||||||
"dev": "bun run ws-server.ts & next dev --turbo",
|
"db:reset": "docker compose rm -s -f -v db && docker compose up -d db && sleep 2 && bun db:seed",
|
||||||
|
"db:restart": "docker compose restart db",
|
||||||
|
"dev": "bun run dev:ws & next dev",
|
||||||
"dev:ws": "bun run ws-server.ts",
|
"dev:ws": "bun run ws-server.ts",
|
||||||
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
|
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
|
||||||
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
|
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "bun run start:ws & next start",
|
||||||
|
"start:ws": "bun run ws-server.ts",
|
||||||
|
"start:web": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -75,12 +79,12 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.45.2",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "16.2.1",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Homepage Screenshots
|
||||||
|
|
||||||
|
Add your app screenshots here. The homepage will display them automatically.
|
||||||
|
|
||||||
|
## Required Screenshots
|
||||||
|
|
||||||
|
1. **experiment-designer.png** - Visual experiment designer showing block-based workflow
|
||||||
|
2. **wizard-interface.png** - Wizard execution interface with trial controls
|
||||||
|
3. **dashboard.png** - Study dashboard showing experiments and trials
|
||||||
|
|
||||||
|
## Recommended Size
|
||||||
|
|
||||||
|
- Width: 1200px
|
||||||
|
- Format: PNG or WebP
|
||||||
|
- Quality: High (screenshot at 2x for retina displays)
|
||||||
|
|
||||||
|
## Preview in Browser
|
||||||
|
|
||||||
|
After adding screenshots, uncomment the `<Image>` component in `src/app/page.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Image
|
||||||
|
src={screenshot.src}
|
||||||
|
alt={screenshot.alt}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
```
|
||||||
+1
-1
Submodule robot-plugins updated: d772aecc54...8334b809f2
@@ -0,0 +1,18 @@
|
|||||||
|
# Mock Robot Configuration
|
||||||
|
# Copy this file to .env and adjust as needed
|
||||||
|
|
||||||
|
# Port for mock robot WebSocket server (default: 9090, same as rosbridge)
|
||||||
|
MOCK_ROBOT_PORT=9090
|
||||||
|
|
||||||
|
# How often to publish robot state (ms)
|
||||||
|
MOCK_PUBLISH_INTERVAL=100
|
||||||
|
|
||||||
|
# Robot configuration
|
||||||
|
MOCK_ROBOT_NAME=MOCK-NAO6
|
||||||
|
MOCK_ROBOT_VERSION=6.0
|
||||||
|
MOCK_BATTERY_LEVEL=85
|
||||||
|
|
||||||
|
# Enable simulation features
|
||||||
|
MOCK_ENABLE_SPEECH=true
|
||||||
|
MOCK_ENABLE_MOVEMENT=true
|
||||||
|
MOCK_ENABLE_SENSORS=true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@hristudio/mock-robot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Mock robot server for simulating NAO6 robot connections",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
|
||||||
|
interface RosMessage {
|
||||||
|
op: string;
|
||||||
|
topic?: string;
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
msg?: Record<string, unknown>;
|
||||||
|
service?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscriber {
|
||||||
|
id: string;
|
||||||
|
topic: string;
|
||||||
|
type: string;
|
||||||
|
ws: WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.MOCK_ROBOT_PORT || "9090", 10);
|
||||||
|
const PUBLISH_INTERVAL = parseInt(process.env.MOCK_PUBLISH_INTERVAL || "100", 10);
|
||||||
|
|
||||||
|
const subscribers: Map<string, Subscriber> = new Map();
|
||||||
|
let subscriberIdCounter = 0;
|
||||||
|
|
||||||
|
const mockRobotState = {
|
||||||
|
battery: 85,
|
||||||
|
position: { x: 0, y: 0, theta: 0 },
|
||||||
|
joints: [
|
||||||
|
"HeadYaw",
|
||||||
|
"HeadPitch",
|
||||||
|
"LShoulderPitch",
|
||||||
|
"LShoulderRoll",
|
||||||
|
"LElbowYaw",
|
||||||
|
"LElbowRoll",
|
||||||
|
"LWristYaw",
|
||||||
|
"LHand",
|
||||||
|
"RShoulderPitch",
|
||||||
|
"RShoulderRoll",
|
||||||
|
"RElbowYaw",
|
||||||
|
"RElbowRoll",
|
||||||
|
"RWristYaw",
|
||||||
|
"RHand",
|
||||||
|
"LHipYawPitch",
|
||||||
|
"LHipRoll",
|
||||||
|
"LHipPitch",
|
||||||
|
"LKneePitch",
|
||||||
|
"LAnklePitch",
|
||||||
|
"LAnkleRoll",
|
||||||
|
"RHipYawPitch",
|
||||||
|
"RHipRoll",
|
||||||
|
"RHipPitch",
|
||||||
|
"RKneePitch",
|
||||||
|
"RAnklePitch",
|
||||||
|
"RAnkleRoll",
|
||||||
|
],
|
||||||
|
jointPositions: new Array(26).fill(0).map(() => (Math.random() - 0.5) * 0.1),
|
||||||
|
bumperLeft: false,
|
||||||
|
bumperRight: false,
|
||||||
|
handTouchLeft: false,
|
||||||
|
handTouchRight: false,
|
||||||
|
headTouchFront: false,
|
||||||
|
headTouchMiddle: false,
|
||||||
|
headTouchRear: false,
|
||||||
|
sonarLeft: 0.5 + Math.random() * 0.5,
|
||||||
|
sonarRight: 0.5 + Math.random() * 0.5,
|
||||||
|
lastSpeechText: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function broadcastToSubscribers(topic: string, msg: Record<string, unknown>, type: string): void {
|
||||||
|
const message = JSON.stringify({
|
||||||
|
op: "publish",
|
||||||
|
topic,
|
||||||
|
type,
|
||||||
|
msg,
|
||||||
|
});
|
||||||
|
|
||||||
|
subscribers.forEach((sub) => {
|
||||||
|
if (sub.topic === topic && sub.ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
sub.ws.send(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to send to subscriber ${sub.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishRobotState(): void {
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/joint_states",
|
||||||
|
{
|
||||||
|
header: { stamp: { sec: Math.floor(Date.now() / 1000), nanosec: 0 }, frame_id: "" },
|
||||||
|
name: mockRobotState.joints,
|
||||||
|
position: mockRobotState.jointPositions,
|
||||||
|
velocity: new Array(26).fill(0),
|
||||||
|
effort: new Array(26).fill(0),
|
||||||
|
},
|
||||||
|
"sensor_msgs/JointState"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/naoqi_driver/battery",
|
||||||
|
{ header: {}, percentage: mockRobotState.battery, charging: false, plug: false },
|
||||||
|
"naoqi_bridge_msgs/Bumper"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/bumper",
|
||||||
|
{ left: mockRobotState.bumperLeft, right: mockRobotState.bumperRight },
|
||||||
|
"naoqi_bridge_msgs/Bumper"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/hand_touch",
|
||||||
|
{
|
||||||
|
leftHand: mockRobotState.handTouchLeft,
|
||||||
|
rightHand: mockRobotState.handTouchRight,
|
||||||
|
},
|
||||||
|
"naoqi_bridge_msgs/HandTouch"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/head_touch",
|
||||||
|
{
|
||||||
|
front: mockRobotState.headTouchFront,
|
||||||
|
middle: mockRobotState.headTouchMiddle,
|
||||||
|
rear: mockRobotState.headTouchRear,
|
||||||
|
},
|
||||||
|
"naoqi_bridge_msgs/HeadTouch"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/sonar/left",
|
||||||
|
{ header: {}, radiation_type: 1, field_of_view: 0.5, min_range: 0.1, max_range: 5.0, range: mockRobotState.sonarLeft },
|
||||||
|
"sensor_msgs/Range"
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/sonar/right",
|
||||||
|
{ header: {}, radiation_type: 1, field_of_view: 0.5, min_range: 0.1, max_range: 5.0, range: mockRobotState.sonarRight },
|
||||||
|
"sensor_msgs/Range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(ws: WebSocket, data: string): void {
|
||||||
|
try {
|
||||||
|
const message: RosMessage = JSON.parse(data);
|
||||||
|
console.log(`[MockRobot] Received: ${message.op} ${message.topic || message.service || ""}`);
|
||||||
|
|
||||||
|
switch (message.op) {
|
||||||
|
case "subscribe":
|
||||||
|
handleSubscribe(ws, message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unsubscribe":
|
||||||
|
handleUnsubscribe(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "publish":
|
||||||
|
handlePublish(message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "call_service":
|
||||||
|
handleServiceCall(ws, message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "advertise":
|
||||||
|
console.log(`[MockRobot] Client advertising: ${message.topic}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unadvertise":
|
||||||
|
console.log(`[MockRobot] Client unadvertising: ${message.topic}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "auth":
|
||||||
|
ws.send(JSON.stringify({ op: "auth_result", result: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`[MockRobot] Unknown operation: ${message.op}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[MockRobot] Failed to parse message:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscribe(ws: WebSocket, message: RosMessage): void {
|
||||||
|
if (!message.topic) return;
|
||||||
|
|
||||||
|
const id = `sub_${subscriberIdCounter++}`;
|
||||||
|
const subscriber: Subscriber = {
|
||||||
|
id,
|
||||||
|
topic: message.topic,
|
||||||
|
type: message.type || "unknown",
|
||||||
|
ws,
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribers.set(id, subscriber);
|
||||||
|
console.log(`[MockRobot] Subscribed to ${message.topic} (${id})`);
|
||||||
|
|
||||||
|
if (message.id) {
|
||||||
|
ws.send(JSON.stringify({ op: "subscribe", id: message.id, values: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnsubscribe(message: RosMessage): void {
|
||||||
|
if (!message.id) return;
|
||||||
|
|
||||||
|
const subscriber = subscribers.get(message.id);
|
||||||
|
if (subscriber) {
|
||||||
|
console.log(`[MockRobot] Unsubscribed from ${subscriber.topic}`);
|
||||||
|
subscribers.delete(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePublish(message: RosMessage): void {
|
||||||
|
if (!message.topic || !message.msg) return;
|
||||||
|
|
||||||
|
console.log(`[MockRobot] Publish to ${message.topic}:`, JSON.stringify(message.msg).slice(0, 200));
|
||||||
|
|
||||||
|
if (message.topic === "/cmd_vel") {
|
||||||
|
handleCmdVel(message.msg);
|
||||||
|
} else if (message.topic === "/speech") {
|
||||||
|
handleSpeech(message.msg);
|
||||||
|
} else if (message.topic === "/joint_angles") {
|
||||||
|
handleJointAngles(message.msg);
|
||||||
|
} else if (message.topic === "/autonomous_life/control") {
|
||||||
|
handleAutonomousLife(message.msg);
|
||||||
|
} else if (message.topic === "/leds") {
|
||||||
|
handleLEDs(message.msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCmdVel(msg: Record<string, unknown>): void {
|
||||||
|
const twist = msg as { linear?: { x?: number; y?: number; z?: number }; angular?: { x?: number; y?: number; z?: number } };
|
||||||
|
const linear = twist.linear || {};
|
||||||
|
const angular = twist.angular || {};
|
||||||
|
|
||||||
|
if (angular.z !== undefined && angular.z !== 0) {
|
||||||
|
mockRobotState.position.theta += angular.z * (PUBLISH_INTERVAL / 1000);
|
||||||
|
console.log(`[MockRobot] Turning: angular.z=${angular.z}, new theta=${mockRobotState.position.theta.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linear.x !== undefined && linear.x !== 0) {
|
||||||
|
const dx = linear.x * Math.cos(mockRobotState.position.theta) * (PUBLISH_INTERVAL / 1000);
|
||||||
|
const dy = linear.x * Math.sin(mockRobotState.position.theta) * (PUBLISH_INTERVAL / 1000);
|
||||||
|
mockRobotState.position.x += dx;
|
||||||
|
mockRobotState.position.y += dy;
|
||||||
|
console.log(`[MockRobot] Walking: linear.x=${linear.x}, pos=(${mockRobotState.position.x.toFixed(2)}, ${mockRobotState.position.y.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSpeech(msg: Record<string, unknown>): void {
|
||||||
|
const text = (msg as { data?: string }).data || "";
|
||||||
|
mockRobotState.lastSpeechText = text;
|
||||||
|
console.log(`[MockRobot] Speaking: "${text}"`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
broadcastToSubscribers(
|
||||||
|
"/speech/status",
|
||||||
|
{ state: "done", text },
|
||||||
|
"std_msgs/String"
|
||||||
|
);
|
||||||
|
console.log(`[MockRobot] Speech complete: "${text}"`);
|
||||||
|
}, Math.max(500, text.split(/\s+/).length * 300 + 1500));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJointAngles(msg: Record<string, unknown>): void {
|
||||||
|
const data = msg as {
|
||||||
|
joint_names?: string[];
|
||||||
|
joint_angles?: number[];
|
||||||
|
speed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.joint_names && data.joint_angles && Array.isArray(data.joint_angles)) {
|
||||||
|
const jointAngles = data.joint_angles;
|
||||||
|
data.joint_names.forEach((name, i) => {
|
||||||
|
const idx = mockRobotState.joints.indexOf(name);
|
||||||
|
const angle = jointAngles[i];
|
||||||
|
if (idx >= 0 && angle !== undefined) {
|
||||||
|
mockRobotState.jointPositions[idx] = angle;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`[MockRobot] Joint angles updated: ${data.joint_names.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAutonomousLife(msg: Record<string, unknown>): void {
|
||||||
|
const state = (msg as { data?: string }).data || "disabled";
|
||||||
|
console.log(`[MockRobot] Autonomous life: ${state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLEDs(msg: Record<string, unknown>): void {
|
||||||
|
const ledName = (msg as { name?: string }).name || "unknown";
|
||||||
|
const color = (msg as { color?: string }).color || "unknown";
|
||||||
|
console.log(`[MockRobot] LED ${ledName} set to ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServiceCall(ws: WebSocket, message: RosMessage): void {
|
||||||
|
const service = message.service || "";
|
||||||
|
const id = message.id || `svc_${Date.now()}`;
|
||||||
|
const args = message.args || {};
|
||||||
|
|
||||||
|
console.log(`[MockRobot] Service call: ${service}`, args);
|
||||||
|
|
||||||
|
let response: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
switch (service) {
|
||||||
|
case "/rosapi/get_param":
|
||||||
|
response = { value: args.param || "" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/rosapi/topics_for_type":
|
||||||
|
response = { topics: [] };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/rosapi/get_topic_type":
|
||||||
|
response = { type: "" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/rosapi/get_node_details":
|
||||||
|
response = { node_api: "", publications: [], subscriptions: [], services: [] };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/get_robot_info":
|
||||||
|
response = {
|
||||||
|
robotName: "MOCK-NAO6",
|
||||||
|
robotVersion: "6.0",
|
||||||
|
bodyType: "nao",
|
||||||
|
headTiltAngle: 0,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/get_joint_names":
|
||||||
|
response = { joint_names: mockRobotState.joints };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/get_position":
|
||||||
|
response = {
|
||||||
|
x: mockRobotState.position.x,
|
||||||
|
y: mockRobotState.position.y,
|
||||||
|
theta: mockRobotState.position.theta,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/is_waking_up":
|
||||||
|
response = { success: true, is_waking_up: false, is_webots: false };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/robot_supports":
|
||||||
|
response = { supports_service: true };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/set_autonomous_state":
|
||||||
|
response = { success: true };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/toggle_autonomous":
|
||||||
|
response = { success: true };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/call_button_action":
|
||||||
|
response = { success: true, button_id: (args as { button_id?: string }).button_id };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/naoqi_driver/robot_batch_request":
|
||||||
|
response = { success: true };
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`[MockRobot] Unknown service: ${service}`);
|
||||||
|
response = { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
op: "service_response",
|
||||||
|
id,
|
||||||
|
service,
|
||||||
|
result: true,
|
||||||
|
values: response,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: PORT });
|
||||||
|
|
||||||
|
console.log(`[MockRobot] Mock Robot Server starting on ws://localhost:${PORT}`);
|
||||||
|
console.log(`[MockRobot] Publish interval: ${PUBLISH_INTERVAL}ms`);
|
||||||
|
console.log("[MockRobot] Simulating NAO6 robot with rosbridge protocol\n");
|
||||||
|
|
||||||
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
|
console.log("[MockRobot] Client connected");
|
||||||
|
|
||||||
|
ws.on("message", (data: Buffer) => {
|
||||||
|
handleMessage(ws, data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
console.log("[MockRobot] Client disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (error) => {
|
||||||
|
console.error("[MockRobot] WebSocket error:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ op: "connected", id: "mock_robot_server" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(publishRobotState, PUBLISH_INTERVAL);
|
||||||
|
|
||||||
|
console.log(`[MockRobot] Server ready. Connect via WebSocket to ws://localhost:${PORT}`);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
+6
-1
@@ -88,7 +88,8 @@ async function main() {
|
|||||||
await db.delete(schema.participants).where(sql`1=1`);
|
await db.delete(schema.participants).where(sql`1=1`);
|
||||||
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.formResponses).where(sql`1=1`);
|
||||||
|
await db.delete(schema.forms).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`);
|
||||||
@@ -236,6 +237,7 @@ async function main() {
|
|||||||
description: "A comprehensive informed consent document template for HRI research studies.",
|
description: "A comprehensive informed consent document template for HRI research studies.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Informed Consent",
|
templateName: "Informed Consent",
|
||||||
|
version: 100,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Study Title", required: true },
|
{ id: "1", type: "text", label: "Study Title", required: true },
|
||||||
{ id: "2", type: "text", label: "Principal Investigator Name", required: true },
|
{ id: "2", type: "text", label: "Principal Investigator Name", required: true },
|
||||||
@@ -261,6 +263,7 @@ async function main() {
|
|||||||
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
|
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Post-Session Survey",
|
templateName: "Post-Session Survey",
|
||||||
|
version: 101,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
|
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
|
||||||
{ id: "2", type: "rating", label: "How understandable was the robot's speech?", required: true, settings: { scale: 5 } },
|
{ id: "2", type: "rating", label: "How understandable was the robot's speech?", required: true, settings: { scale: 5 } },
|
||||||
@@ -283,6 +286,7 @@ async function main() {
|
|||||||
description: "Basic demographic information collection form.",
|
description: "Basic demographic information collection form.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Demographics",
|
templateName: "Demographics",
|
||||||
|
version: 102,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Age", required: true },
|
{ id: "1", type: "text", label: "Age", required: true },
|
||||||
{ id: "2", type: "multiple_choice", label: "Gender", required: true, options: ["Male", "Female", "Non-binary", "Prefer not to say"] },
|
{ id: "2", type: "multiple_choice", label: "Gender", required: true, options: ["Male", "Female", "Non-binary", "Prefer not to say"] },
|
||||||
@@ -302,6 +306,7 @@ async function main() {
|
|||||||
type: "consent",
|
type: "consent",
|
||||||
title: "Interactive Storyteller Consent",
|
title: "Interactive Storyteller Consent",
|
||||||
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
|
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
|
||||||
|
version: 1,
|
||||||
active: true,
|
active: true,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Participant Name", required: true },
|
{ id: "1", type: "text", label: "Participant Name", required: true },
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export default function HelpCenterPage() {
|
|||||||
description: "Learn the basics of HRIStudio and set up your first study.",
|
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Platform Overview", href: "#" },
|
{ label: "Tutorials Overview", href: "/help/tutorials" },
|
||||||
{ label: "Creating a New Study", href: "#" },
|
{ label: "Getting Started Guide", href: "/help/tutorials/getting-started" },
|
||||||
{ label: "Managing Team Members", href: "#" },
|
{ label: "Your First Study", href: "/help/tutorials/your-first-study" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,9 +36,9 @@ export default function HelpCenterPage() {
|
|||||||
description: "Master the visual experiment designer and flow control.",
|
description: "Master the visual experiment designer and flow control.",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Using the Visual Designer", href: "#" },
|
{ label: "Visual Designer Guide", href: "/help/tutorials/designing-experiments" },
|
||||||
{ label: "Robot Actions & Plugins", href: "#" },
|
{ label: "Robot Actions & Plugins", href: "/help/tutorials/robot-integration" },
|
||||||
{ label: "Variables & Logic", href: "#" },
|
{ label: "Wizard Interface", href: "/help/tutorials/wizard-interface" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,9 +46,9 @@ export default function HelpCenterPage() {
|
|||||||
description: "Execute experiments and manage Wizard of Oz sessions.",
|
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||||
icon: PlayCircle,
|
icon: PlayCircle,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Wizard Interface Guide", href: "#" },
|
{ label: "Running Trials Guide", href: "/help/tutorials/running-trials" },
|
||||||
{ label: "Participant Management", href: "#" },
|
{ label: "Participant Management", href: "/help/tutorials/your-first-study" },
|
||||||
{ label: "Handling Robot Errors", href: "#" },
|
{ label: "Simulation Mode", href: "/help/tutorials/simulation-mode" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,9 +56,9 @@ export default function HelpCenterPage() {
|
|||||||
description: "Analyze trial results and export research data.",
|
description: "Analyze trial results and export research data.",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Understanding Analytics", href: "#" },
|
{ label: "Data & Analysis Guide", href: "/help/tutorials/data-and-analysis" },
|
||||||
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
{ label: "Forms & Surveys", href: "/help/tutorials/forms-and-surveys" },
|
||||||
{ label: "Video Replay & Annotation", href: "#" },
|
{ label: "Exporting Data", href: "/help/tutorials/data-and-analysis" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function DataAndAnalysisTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Data & Analysis"
|
||||||
|
description="Collect and export trial data"
|
||||||
|
duration="15 min"
|
||||||
|
level="Intermediate"
|
||||||
|
steps={[
|
||||||
|
{ title: "Understand data collection", description: "" },
|
||||||
|
{ title: "Access trial data", description: "" },
|
||||||
|
{ title: "Export data formats", description: "" },
|
||||||
|
{ title: "Use the analytics dashboard", description: "" },
|
||||||
|
{ title: "Generate reports", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Forms & Surveys",
|
||||||
|
href: "/help/tutorials/forms-and-surveys",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Simulation Mode",
|
||||||
|
href: "/help/tutorials/simulation-mode",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Data Collection Overview</h2>
|
||||||
|
<p>HRIStudio automatically captures comprehensive data during trials:</p>
|
||||||
|
<pre><code>Trial Data
|
||||||
|
├── Trial Metadata
|
||||||
|
│ ├── Start/End times
|
||||||
|
│ ├── Duration
|
||||||
|
│ ├── Participant info
|
||||||
|
│ └── Experiment version
|
||||||
|
├── Event Log (Timestamped)
|
||||||
|
│ ├── Step changes
|
||||||
|
│ ├── Action executions
|
||||||
|
│ ├── Robot responses
|
||||||
|
│ └── Wizard interventions
|
||||||
|
├── Form Responses
|
||||||
|
│ ├── Consent forms
|
||||||
|
│ ├── Surveys
|
||||||
|
│ └── Questionnaires
|
||||||
|
└── Sensor Data
|
||||||
|
├── Joint positions
|
||||||
|
├── Touch events
|
||||||
|
└── Audio/video (if enabled)</code></pre>
|
||||||
|
|
||||||
|
<h2>Event Types</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Event Type</th><th>Description</th><th>Data Captured</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>trial_started</td><td>Trial began</td><td>Timestamp</td></tr>
|
||||||
|
<tr><td>step_changed</td><td>New step began</td><td>Step ID, name</td></tr>
|
||||||
|
<tr><td>action_executed</td><td>Robot action</td><td>Action details, duration</td></tr>
|
||||||
|
<tr><td>wizard_response</td><td>Wizard decision</td><td>Selected option</td></tr>
|
||||||
|
<tr><td>intervention</td><td>Wizard intervention</td><td>Type, note</td></tr>
|
||||||
|
<tr><td>trial_completed</td><td>Trial finished</td><td>Summary</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 1: Accessing Trial Data</h2>
|
||||||
|
|
||||||
|
<h3>From Trial List</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Trials</strong> tab</li>
|
||||||
|
<li>Find completed trial</li>
|
||||||
|
<li>Click <strong>View Details</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>From Study Dashboard</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open your study</li>
|
||||||
|
<li>Go to <strong>Data</strong> tab</li>
|
||||||
|
<li>Select trial or view aggregate</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 2: Exporting Data</h2>
|
||||||
|
|
||||||
|
<h3>Export Single Trial</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open trial details</li>
|
||||||
|
<li>Click <strong>Export</strong></li>
|
||||||
|
<li>Select format</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Export Study Data</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open study</li>
|
||||||
|
<li>Go to <strong>Data</strong> tab</li>
|
||||||
|
<li>Click <strong>Export All</strong></li>
|
||||||
|
<li>Select options:
|
||||||
|
<ul>
|
||||||
|
<li>Date range</li>
|
||||||
|
<li>Trial status</li>
|
||||||
|
<li>Include forms</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Export Formats</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Format</th><th>Contents</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>CSV</td><td>Tabular data for spreadsheets</td></tr>
|
||||||
|
<tr><td>JSON</td><td>Full event log with metadata</td></tr>
|
||||||
|
<tr><td>Video</td><td>Screen recording (if enabled)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 3: Analytics Dashboard</h2>
|
||||||
|
<p>View aggregate statistics:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Total Trials</strong> - Number of scheduled trials</li>
|
||||||
|
<li><strong>Completed</strong> - Successfully completed trials</li>
|
||||||
|
<li><strong>Average Duration</strong> - Mean trial time</li>
|
||||||
|
<li><strong>Completion Rate</strong> - % of trials completed</li>
|
||||||
|
<li><strong>Failed</strong> - Trials that failed</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 4: Analyzing Event Data</h2>
|
||||||
|
|
||||||
|
<h3>Timing Analysis</h3>
|
||||||
|
<p>Calculate action durations from event log:</p>
|
||||||
|
<pre><code>{`for event in events:
|
||||||
|
if event.type == 'action_executed':
|
||||||
|
duration = event.get('duration', 0)
|
||||||
|
print(f"{event.actionName}: {duration/1000:.1f}s")`}</code></pre>
|
||||||
|
|
||||||
|
<h3>Intervention Analysis</h3>
|
||||||
|
<p>Track wizard interventions:</p>
|
||||||
|
<pre><code>{`interventions = [e for e in events if e.type == 'intervention']
|
||||||
|
|
||||||
|
by_type = {}
|
||||||
|
for i in interventions:
|
||||||
|
itype = i.data.get('type', 'unknown')
|
||||||
|
by_type[itype] = by_type.get(itype, 0) + 1`}</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 5: Generating Reports</h2>
|
||||||
|
|
||||||
|
<h3>Trial Summary Report</h3>
|
||||||
|
<p>Generate PDF summary with:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Executive summary</li>
|
||||||
|
<li>Timeline of events</li>
|
||||||
|
<li>Metrics and statistics</li>
|
||||||
|
<li>Intervention summary</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Study Report</h3>
|
||||||
|
<p>Aggregate across participants:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Participation rates</li>
|
||||||
|
<li>Timing statistics</li>
|
||||||
|
<li>Intervention totals</li>
|
||||||
|
<li>Branch selection distribution</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Data Privacy</h2>
|
||||||
|
|
||||||
|
<h3>Anonymization</h3>
|
||||||
|
<p>Remove identifying information:</p>
|
||||||
|
<pre><code>{`participant_map = {
|
||||||
|
'P001': 'S001',
|
||||||
|
'P002': 'S002',
|
||||||
|
'P003': 'S003',
|
||||||
|
}`}</code></pre>
|
||||||
|
|
||||||
|
<h2>Best Practices</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Export data regularly (daily/weekly)</li>
|
||||||
|
<li>Store in secure location</li>
|
||||||
|
<li>Follow IRB data retention</li>
|
||||||
|
<li>Backup critical data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/forms-and-surveys">
|
||||||
|
Previous: Forms & Surveys
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/simulation-mode">
|
||||||
|
Next: Simulation Mode
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function DesigningExperimentsTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Designing Experiments"
|
||||||
|
description="Build experiment protocols with the visual designer"
|
||||||
|
duration="25 min"
|
||||||
|
level="Intermediate"
|
||||||
|
steps={[
|
||||||
|
{ title: "Understand the experiment structure", description: "" },
|
||||||
|
{ title: "Navigate the visual designer", description: "" },
|
||||||
|
{ title: "Use core blocks", description: "" },
|
||||||
|
{ title: "Build branching protocols", description: "" },
|
||||||
|
{ title: "Test your experiment", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Your First Study",
|
||||||
|
href: "/help/tutorials/your-first-study",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Running Trials",
|
||||||
|
href: "/help/tutorials/running-trials",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>What is an Experiment?</h2>
|
||||||
|
<p>An <strong>Experiment</strong> defines the protocol for your study:</p>
|
||||||
|
<pre><code>Experiment
|
||||||
|
├── Steps (ordered sequence)
|
||||||
|
│ ├── Actions (robot behaviors)
|
||||||
|
│ ├── Wizard Blocks (human decisions)
|
||||||
|
│ └── Control Flow (loops, branches)
|
||||||
|
├── Robot Actions (from plugins)
|
||||||
|
└── Parameters (configurable values)</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 1: Create an Experiment</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Open your study</li>
|
||||||
|
<li>Go to <strong>Experiments</strong> tab</li>
|
||||||
|
<li>Click <strong>New Experiment</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 2: The Visual Designer</h2>
|
||||||
|
<p>The designer has three main areas:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Block Library</strong> (left) - Drag blocks from here</li>
|
||||||
|
<li><strong>Canvas</strong> (center) - Design your protocol visually</li>
|
||||||
|
<li><strong>Properties Panel</strong> (right) - Configure selected elements</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 3: Block Categories</h2>
|
||||||
|
|
||||||
|
<h3>Events (Triggers)</h3>
|
||||||
|
<p>Start your experiment with these blocks:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Trial Start</td><td>Triggers when trial begins</td></tr>
|
||||||
|
<tr><td>Wizard Button</td><td>Waits for wizard to press a button</td></tr>
|
||||||
|
<tr><td>Timer</td><td>Waits for a specified duration</td></tr>
|
||||||
|
<tr><td>Participant Response</td><td>Waits for participant input</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Wizard Actions</h3>
|
||||||
|
<p>Blocks the wizard can control:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Say Text</td><td>Robot speaks text</td></tr>
|
||||||
|
<tr><td>Play Animation</td><td>Play a predefined animation</td></tr>
|
||||||
|
<tr><td>Show Image</td><td>Display image on robot screen</td></tr>
|
||||||
|
<tr><td>Move Robot</td><td>Move robot to position</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Control Flow</h3>
|
||||||
|
<p>Control experiment progression:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Branch</td><td>Split into multiple paths</td></tr>
|
||||||
|
<tr><td>Loop</td><td>Repeat a sequence</td></tr>
|
||||||
|
<tr><td>Wait</td><td>Pause for duration</td></tr>
|
||||||
|
<tr><td>Converge</td><td>Merge multiple paths back</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 4: Building a Branching Protocol</h2>
|
||||||
|
<p>Let's build "The Interactive Storyteller" - a simple storytelling experiment:</p>
|
||||||
|
|
||||||
|
<h3>Step 1: The Hook (Start)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>+ Add Step</strong></li>
|
||||||
|
<li>Name it "The Hook"</li>
|
||||||
|
<li>Set type to <strong>Robot</strong></li>
|
||||||
|
<li>Drag <strong>Say Text</strong> block</li>
|
||||||
|
<li>Configure: <code>{`{ text: "Hello! I have a story to tell you." }`}</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Step 2: Comprehension Check (Branching)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Add new step "Comprehension Check"</li>
|
||||||
|
<li>Set type to <strong>Conditional</strong></li>
|
||||||
|
<li>Add <strong>Ask Question</strong> block</li>
|
||||||
|
<li>Configure options:
|
||||||
|
<pre><code>{`{
|
||||||
|
question: "What color was the rock?",
|
||||||
|
options: [
|
||||||
|
{ label: "Correct", value: "red" },
|
||||||
|
{ label: "Incorrect", value: "other" }
|
||||||
|
]
|
||||||
|
}`}</code></pre>
|
||||||
|
</li>
|
||||||
|
<li>This creates two paths automatically</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Step 3: Converge Paths</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Add new step "Story Continues"</li>
|
||||||
|
<li>Set type to <strong>Converge</strong></li>
|
||||||
|
<li>Connect both branches to this step</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 5: Testing Your Experiment</h2>
|
||||||
|
|
||||||
|
<h3>Preview Mode</h3>
|
||||||
|
<p>Test your experiment without running a real trial:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Preview</strong> button</li>
|
||||||
|
<li>Step through each block</li>
|
||||||
|
<li>See timing and flow</li>
|
||||||
|
<li>Test branching decisions</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Simulation Mode</h3>
|
||||||
|
<p>Run with a simulated robot:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Enable <code>NEXT_PUBLIC_SIMULATION_MODE=true</code></li>
|
||||||
|
<li>Start a trial</li>
|
||||||
|
<li>Robot actions are logged but not executed</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Common Patterns</h2>
|
||||||
|
|
||||||
|
<h3>Linear Protocol</h3>
|
||||||
|
<pre><code>Start → Step 1 → Step 2 → Step 3 → End</code></pre>
|
||||||
|
|
||||||
|
<h3>Branching Protocol</h3>
|
||||||
|
<pre><code>Start → Step 1
|
||||||
|
├── Condition A → Step 2a
|
||||||
|
└── Condition B → Step 2b</code></pre>
|
||||||
|
|
||||||
|
<h3>Loop Protocol</h3>
|
||||||
|
<pre><code>Start → Step 1 → Loop (3x) → Step 2 → End
|
||||||
|
↑
|
||||||
|
└── (back to Step 1)</code></pre>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/your-first-study">
|
||||||
|
Previous: Your First Study
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/running-trials">
|
||||||
|
Next: Running Trials
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function FormsAndSurveysTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Forms & Surveys"
|
||||||
|
description="Create consent forms and questionnaires"
|
||||||
|
duration="15 min"
|
||||||
|
level="Intermediate"
|
||||||
|
steps={[
|
||||||
|
{ title: "Understand form types", description: "" },
|
||||||
|
{ title: "Create a new form", description: "" },
|
||||||
|
{ title: "Add form fields", description: "" },
|
||||||
|
{ title: "Use form templates", description: "" },
|
||||||
|
{ title: "Collect responses", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Robot Integration",
|
||||||
|
href: "/help/tutorials/robot-integration",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Data & Analysis",
|
||||||
|
href: "/help/tutorials/data-and-analysis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Form Types</h2>
|
||||||
|
<p>HRIStudio supports three form types:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Type</th><th>Purpose</th><th>When</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Consent</td><td>Informed consent for participation</td><td>Before trial</td></tr>
|
||||||
|
<tr><td>Survey</td><td>Collect feedback and observations</td><td>After trial</td></tr>
|
||||||
|
<tr><td>Questionnaire</td><td>Demographic data collection</td><td>Any time</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 1: Access Forms</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Go to your <strong>Study</strong></li>
|
||||||
|
<li>Click <strong>Forms</strong> tab</li>
|
||||||
|
<li>View existing forms and templates</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 2: Create a Form</h2>
|
||||||
|
|
||||||
|
<h3>Using a Template</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Create Form</strong></li>
|
||||||
|
<li>Select <strong>Use Template</strong></li>
|
||||||
|
<li>Choose template:
|
||||||
|
<ul>
|
||||||
|
<li>Informed Consent</li>
|
||||||
|
<li>Post-Session Survey</li>
|
||||||
|
<li>Demographics</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Customize as needed</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>From Scratch</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Create Form</strong></li>
|
||||||
|
<li>Select <strong>Blank Form</strong></li>
|
||||||
|
<li>Choose form type</li>
|
||||||
|
<li>Build fields manually</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 3: Form Field Types</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Field Type</th><th>Description</th><th>Example</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Text</td><td>Single line text input</td><td>Participant name</td></tr>
|
||||||
|
<tr><td>Text Area</td><td>Multi-line text</td><td>Open-ended feedback</td></tr>
|
||||||
|
<tr><td>Rating</td><td>Scale rating</td><td>Rate 1-5</td></tr>
|
||||||
|
<tr><td>Multiple Choice</td><td>Select one option</td><td>Gender selection</td></tr>
|
||||||
|
<tr><td>Yes/No</td><td>Binary choice</td><td>Consent checkbox</td></tr>
|
||||||
|
<tr><td>Date</td><td>Date picker</td><td>Session date</td></tr>
|
||||||
|
<tr><td>Signature</td><td>Digital signature</td><td>Consent signature</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 4: Consent Forms</h2>
|
||||||
|
<p>For IRB compliance, consent forms must include:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Study title and purpose</li>
|
||||||
|
<li>Principal investigator</li>
|
||||||
|
<li>Procedures description</li>
|
||||||
|
<li>Risks and benefits</li>
|
||||||
|
<li>Confidentiality statement</li>
|
||||||
|
<li>Voluntary participation note</li>
|
||||||
|
<li>Signature and date fields</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 5: Distributing Forms</h2>
|
||||||
|
|
||||||
|
<h3>Automatic Distribution</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open form settings</li>
|
||||||
|
<li>Enable <strong>Auto-distribute</strong></li>
|
||||||
|
<li>Set trigger:
|
||||||
|
<ul>
|
||||||
|
<li>Before trial (consent)</li>
|
||||||
|
<li>After trial (survey)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Select participants</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Manual Distribution</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open form</li>
|
||||||
|
<li>Click <strong>Distribute</strong></li>
|
||||||
|
<li>Select participants</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 6: Collecting Responses</h2>
|
||||||
|
|
||||||
|
<h3>View Responses</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open form</li>
|
||||||
|
<li>Click <strong>Responses</strong> tab</li>
|
||||||
|
<li>View individual submissions</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Export Responses</h3>
|
||||||
|
<p>Download collected data:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Format</th><th>Contents</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>CSV</td><td>Tabular data</td></tr>
|
||||||
|
<tr><td>JSON</td><td>Full response objects</td></tr>
|
||||||
|
<tr><td>PDF</td><td>Printed consent forms</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Form Templates</h2>
|
||||||
|
<p>Pre-built templates available:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Template</th><th>Use Case</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Standard Consent</td><td>Generic research consent</td></tr>
|
||||||
|
<tr><td>Post-Session Survey</td><td>Post-session feedback</td></tr>
|
||||||
|
<tr><td>Demographics</td><td>Participant information</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/robot-integration">
|
||||||
|
Previous: Robot Integration
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/data-and-analysis">
|
||||||
|
Next: Data & Analysis
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function GettingStartedTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Getting Started"
|
||||||
|
description="Set up HRIStudio and learn the basics"
|
||||||
|
duration="10 min"
|
||||||
|
level="Beginner"
|
||||||
|
steps={[
|
||||||
|
{ title: "Clone and install the repository", description: "" },
|
||||||
|
{ title: "Start the database with Docker", description: "" },
|
||||||
|
{ title: "Seed the database with sample data", description: "" },
|
||||||
|
{ title: "Start the development server", description: "" },
|
||||||
|
{ title: "Log in and explore the interface", description: "" },
|
||||||
|
]}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Your First Study",
|
||||||
|
href: "/help/tutorials/your-first-study",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Prerequisites</h2>
|
||||||
|
<p>Before you begin, make sure you have the following installed:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Bun</strong> - The package manager for HRIStudio</li>
|
||||||
|
<li><strong>Docker</strong> - For running PostgreSQL and MinIO</li>
|
||||||
|
<li><strong>Git</strong> - For version control</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 1: Clone the Repository</h2>
|
||||||
|
<p>Start by cloning the HRIStudio repository:</p>
|
||||||
|
<pre><code>git clone https://github.com/soconnor0919/hristudio.git
|
||||||
|
cd hristudio</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 2: Install Dependencies</h2>
|
||||||
|
<p>HRIStudio uses Bun as its package manager:</p>
|
||||||
|
<pre><code>bun install</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 3: Start the Database</h2>
|
||||||
|
<p>HRIStudio requires PostgreSQL. The easiest way is using Docker:</p>
|
||||||
|
<pre><code># Start PostgreSQL and MinIO (for file storage)
|
||||||
|
bun run docker:up
|
||||||
|
|
||||||
|
# Push database schema
|
||||||
|
bun db:push
|
||||||
|
|
||||||
|
# Seed with sample data
|
||||||
|
bun db:seed</code></pre>
|
||||||
|
<p className="bg-muted p-4 rounded-lg border">
|
||||||
|
<strong>Note:</strong> This creates the database schema and populates it with
|
||||||
|
sample users, studies, and experiments so you can explore the platform immediately.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Step 4: Start the Development Server</h2>
|
||||||
|
<pre><code>bun dev</code></pre>
|
||||||
|
<p>The application will be available at <code>http://localhost:3000</code>.</p>
|
||||||
|
|
||||||
|
<h2>Step 5: Log In</h2>
|
||||||
|
<p>Use one of the default accounts:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Password</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Administrator</td>
|
||||||
|
<td><code>sean@soconnor.dev</code></td>
|
||||||
|
<td><code>password123</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Researcher</td>
|
||||||
|
<td><code>felipe.perrone@bucknell.edu</code></td>
|
||||||
|
<td><code>password123</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Wizard</td>
|
||||||
|
<td><code>emily.watson@lab.edu</code></td>
|
||||||
|
<td><code>password123</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Observer</td>
|
||||||
|
<td><code>maria.santos@tech.edu</code></td>
|
||||||
|
<td><code>password123</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Exploring the Interface</h2>
|
||||||
|
<p>After logging in, you'll see the main dashboard with navigation to:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Studies</strong> - View and manage your research studies</li>
|
||||||
|
<li><strong>Trials</strong> - Monitor and manage experiment trials</li>
|
||||||
|
<li><strong>Plugins</strong> - Manage robot integrations</li>
|
||||||
|
<li><strong>Admin</strong> - System administration (admins only)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Using Simulation Mode</h2>
|
||||||
|
<p>If you don't have a physical robot, enable simulation mode:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Create or edit <code>hristudio/.env.local</code></li>
|
||||||
|
<li>Add: <code>NEXT_PUBLIC_SIMULATION_MODE=true</code></li>
|
||||||
|
<li>Restart the dev server</li>
|
||||||
|
</ol>
|
||||||
|
<p>Simulation mode allows you to test experiments without connecting to a real robot.</p>
|
||||||
|
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
|
||||||
|
<h3>Database Connection Failed</h3>
|
||||||
|
<pre><code># Check if Docker is running
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Restart the database
|
||||||
|
bun run docker:down
|
||||||
|
bun run docker:up
|
||||||
|
bun db:push</code></pre>
|
||||||
|
|
||||||
|
<h3>Port Already in Use</h3>
|
||||||
|
<p>If port 3000 is in use:</p>
|
||||||
|
<pre><code>PORT=3001 bun dev</code></pre>
|
||||||
|
|
||||||
|
<h3>Seed Script Fails</h3>
|
||||||
|
<pre><code># Reset the database
|
||||||
|
bun run docker:down -v
|
||||||
|
bun run docker:up
|
||||||
|
bun db:push
|
||||||
|
bun db:seed</code></pre>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/your-first-study">
|
||||||
|
Next: Your First Study
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
FlaskConical,
|
||||||
|
PlayCircle,
|
||||||
|
BarChart3,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
ClipboardList,
|
||||||
|
Layers,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { PageLayout } from "~/components/ui/page-layout";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const tutorials = [
|
||||||
|
{
|
||||||
|
slug: "getting-started",
|
||||||
|
title: "Getting Started",
|
||||||
|
description: "Set up HRIStudio and learn the basics",
|
||||||
|
icon: BookOpen,
|
||||||
|
duration: "10 min",
|
||||||
|
level: "Beginner",
|
||||||
|
href: "/help/tutorials/getting-started",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "your-first-study",
|
||||||
|
title: "Your First Study",
|
||||||
|
description: "Create a research study and manage team members",
|
||||||
|
icon: Layers,
|
||||||
|
duration: "15 min",
|
||||||
|
level: "Beginner",
|
||||||
|
href: "/help/tutorials/your-first-study",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "designing-experiments",
|
||||||
|
title: "Designing Experiments",
|
||||||
|
description: "Build experiment protocols with the visual designer",
|
||||||
|
icon: FlaskConical,
|
||||||
|
duration: "25 min",
|
||||||
|
level: "Intermediate",
|
||||||
|
href: "/help/tutorials/designing-experiments",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "running-trials",
|
||||||
|
title: "Running Trials",
|
||||||
|
description: "Execute experiments and manage participants",
|
||||||
|
icon: PlayCircle,
|
||||||
|
duration: "20 min",
|
||||||
|
level: "Intermediate",
|
||||||
|
href: "/help/tutorials/running-trials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "wizard-interface",
|
||||||
|
title: "Wizard Interface",
|
||||||
|
description: "Real-time trial control and monitoring",
|
||||||
|
icon: Bot,
|
||||||
|
duration: "15 min",
|
||||||
|
level: "Intermediate",
|
||||||
|
href: "/help/tutorials/wizard-interface",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "robot-integration",
|
||||||
|
title: "Robot Integration",
|
||||||
|
description: "Connect NAO6 and configure robot plugins",
|
||||||
|
icon: ClipboardList,
|
||||||
|
duration: "20 min",
|
||||||
|
level: "Advanced",
|
||||||
|
href: "/help/tutorials/robot-integration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "forms-and-surveys",
|
||||||
|
title: "Forms & Surveys",
|
||||||
|
description: "Create consent forms and questionnaires",
|
||||||
|
icon: FileText,
|
||||||
|
duration: "15 min",
|
||||||
|
level: "Intermediate",
|
||||||
|
href: "/help/tutorials/forms-and-surveys",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "data-and-analysis",
|
||||||
|
title: "Data & Analysis",
|
||||||
|
description: "Collect and export trial data",
|
||||||
|
icon: BarChart3,
|
||||||
|
duration: "15 min",
|
||||||
|
level: "Intermediate",
|
||||||
|
href: "/help/tutorials/data-and-analysis",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
Beginner: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||||
|
Intermediate: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
|
||||||
|
Advanced: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TutorialsPage() {
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
title="Tutorials"
|
||||||
|
description="Step-by-step guides for learning HRIStudio"
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: "Help", href: "/help" },
|
||||||
|
{ label: "Tutorials" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Quick Start Path</h2>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Follow this sequence to go from setup to running your first trial.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{tutorials.slice(0, 5).map((tutorial, index) => (
|
||||||
|
<div key={tutorial.slug} className="flex items-center gap-2">
|
||||||
|
<Link href={tutorial.href}>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{tutorial.title}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{index < 4 && <ArrowRight className="text-muted-foreground h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{tutorials.map((tutorial) => (
|
||||||
|
<Link key={tutorial.slug} href={tutorial.href}>
|
||||||
|
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<tutorial.icon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${levelColors[tutorial.level]}`}
|
||||||
|
>
|
||||||
|
{tutorial.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{tutorial.title}</CardTitle>
|
||||||
|
<CardDescription>{tutorial.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{tutorial.duration}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">By Role</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Researchers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<Link href="/help/tutorials/getting-started" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Getting Started
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/your-first-study" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Your First Study
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/designing-experiments" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Designing Experiments
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/data-and-analysis" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Data & Analysis
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Wizards</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<Link href="/help/tutorials/getting-started" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Getting Started
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/wizard-interface" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Wizard Interface
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/robot-integration" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Robot Integration
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Administrators</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<Link href="/help/tutorials/getting-started" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Getting Started
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/robot-integration" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Robot Integration
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/forms-and-surveys" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Forms & Surveys
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Pilot Testing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<Link href="/help/tutorials/getting-started" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Getting Started
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/designing-experiments" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Designing Experiments
|
||||||
|
</Link>
|
||||||
|
<Link href="/help/tutorials/running-trials" className="block text-muted-foreground hover:text-foreground">
|
||||||
|
Running Trials
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function RobotIntegrationTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Robot Integration"
|
||||||
|
description="Connect NAO6 and configure robot plugins"
|
||||||
|
duration="20 min"
|
||||||
|
level="Advanced"
|
||||||
|
steps={[
|
||||||
|
{ title: "Set up the NAO6 robot", description: "" },
|
||||||
|
{ title: "Start Docker services", description: "" },
|
||||||
|
{ title: "Configure HRIStudio", description: "" },
|
||||||
|
{ title: "Test the connection", description: "" },
|
||||||
|
{ title: "Troubleshoot common issues", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Wizard Interface",
|
||||||
|
href: "/help/tutorials/wizard-interface",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Forms & Surveys",
|
||||||
|
href: "/help/tutorials/forms-and-surveys",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Supported Robots</h2>
|
||||||
|
<p>HRIStudio supports multiple robot platforms:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Robot</th><th>Protocol</th><th>Capabilities</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>NAO6</td><td>ROS2</td><td>Speech, movement, gestures, sensors</td></tr>
|
||||||
|
<tr><td>TurtleBot3</td><td>ROS2</td><td>Navigation, sensors</td></tr>
|
||||||
|
<tr><td>Mock Robot</td><td>WebSocket</td><td>All actions (simulation)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 1: Set Up NAO6 Robot</h2>
|
||||||
|
|
||||||
|
<h3>Network Configuration</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Connect NAO6 to your network</li>
|
||||||
|
<li>Note the robot's IP address:
|
||||||
|
<pre><code># On the robot, say "What is my IP address?"
|
||||||
|
# Or check robot's network settings</code></pre>
|
||||||
|
</li>
|
||||||
|
<li>Verify network access:
|
||||||
|
<pre><code>ping nao.local
|
||||||
|
# Or ping the IP directly:
|
||||||
|
ping 192.168.1.100</code></pre>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Wake Up Robot</h3>
|
||||||
|
<p>Before connecting, wake up the robot:</p>
|
||||||
|
<pre><code>ssh nao@192.168.1.100
|
||||||
|
# Enter password when prompted
|
||||||
|
|
||||||
|
# Wake up the robot
|
||||||
|
python -c "from naoqi import ALProxy; proxy = ALProxy('ALMotion', '192.168.1.100', 9559); proxy.wakeUp()"</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 2: Start Docker Services</h2>
|
||||||
|
|
||||||
|
<pre><code>cd ~/nao6-hristudio-integration
|
||||||
|
|
||||||
|
# Set robot IP
|
||||||
|
export NAO_IP=192.168.1.100
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker compose up -d</code></pre>
|
||||||
|
|
||||||
|
<h3>Services Overview</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Service</th><th>Port</th><th>Purpose</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>nao_driver</td><td>-</td><td>ROS2 driver for NAO</td></tr>
|
||||||
|
<tr><td>ros_bridge</td><td>9090</td><td>WebSocket bridge</td></tr>
|
||||||
|
<tr><td>ros_api</td><td>-</td><td>Topic introspection</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 3: Configure HRIStudio</h2>
|
||||||
|
|
||||||
|
<h3>Install Robot Plugin</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Plugins</strong> in sidebar</li>
|
||||||
|
<li>Select your study</li>
|
||||||
|
<li>Click <strong>Browse Plugins</strong></li>
|
||||||
|
<li>Find <strong>NAO6 Robot (ROS2 Integration)</strong></li>
|
||||||
|
<li>Click <strong>Install</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Configure Plugin</h3>
|
||||||
|
<pre><code>Robot Name: NAO6-Lab
|
||||||
|
Robot IP: 192.168.1.100
|
||||||
|
WebSocket URL: ws://localhost:9090</code></pre>
|
||||||
|
|
||||||
|
<h3>Environment Variables</h3>
|
||||||
|
<p>Create <code>hristudio/.env.local</code>:</p>
|
||||||
|
<pre><code># Robot connection
|
||||||
|
NAO_ROBOT_IP=192.168.1.100
|
||||||
|
NAO_PASSWORD=robolab
|
||||||
|
NAO_USERNAME=nao
|
||||||
|
|
||||||
|
# WebSocket bridge
|
||||||
|
NEXT_PUBLIC_ROS_BRIDGE_URL=ws://localhost:9090</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 4: Test Connection</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to: <code>http://localhost:3000/nao-test</code></li>
|
||||||
|
<li>Click <strong>Connect</strong></li>
|
||||||
|
<li>Verify connection status shows "Connected"</li>
|
||||||
|
<li>Test basic actions (Say, Wave, Move)</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Robot Actions Reference</h2>
|
||||||
|
|
||||||
|
<h3>Speech Actions</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Action</th><th>Parameters</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>say_text</td><td>text</td><td>Speak text</td></tr>
|
||||||
|
<tr><td>say_with_emotion</td><td>text, emotion</td><td>Emotional speech</td></tr>
|
||||||
|
<tr><td>set_volume</td><td>level</td><td>Set speech volume</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Movement Actions</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Action</th><th>Parameters</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>walk_forward</td><td>speed, duration</td><td>Walk forward</td></tr>
|
||||||
|
<tr><td>walk_backward</td><td>speed</td><td>Walk backward</td></tr>
|
||||||
|
<tr><td>turn_left</td><td>speed</td><td>Turn left</td></tr>
|
||||||
|
<tr><td>turn_right</td><td>speed</td><td>Turn right</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
|
||||||
|
<h3>Robot Not Found</h3>
|
||||||
|
<pre><code>Error: Cannot connect to robot at 192.168.1.100
|
||||||
|
|
||||||
|
Solutions:
|
||||||
|
1. Verify IP address: ping 192.168.1.100
|
||||||
|
2. Check robot is powered on
|
||||||
|
3. Verify network connectivity
|
||||||
|
4. Try nao.local hostname</code></pre>
|
||||||
|
|
||||||
|
<h3>WebSocket Connection Failed</h3>
|
||||||
|
<pre><code>Error: WebSocket connection to ws://localhost:9090 failed
|
||||||
|
|
||||||
|
Solutions:
|
||||||
|
1. Check Docker is running: docker ps
|
||||||
|
2. Verify ros_bridge container
|
||||||
|
3. Check port 9090 is not blocked
|
||||||
|
4. Restart services: docker compose restart</code></pre>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/wizard-interface">
|
||||||
|
Previous: Wizard Interface
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/forms-and-surveys">
|
||||||
|
Next: Forms & Surveys
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function RunningTrialsTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Running Trials"
|
||||||
|
description="Execute experiments and manage participant trials"
|
||||||
|
duration="20 min"
|
||||||
|
level="Intermediate"
|
||||||
|
steps={[
|
||||||
|
{ title: "Schedule a trial", description: "" },
|
||||||
|
{ title: "Prepare for trial execution", description: "" },
|
||||||
|
{ title: "Start and monitor the trial", description: "" },
|
||||||
|
{ title: "Handle interventions", description: "" },
|
||||||
|
{ title: "Complete and review the trial", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Designing Experiments",
|
||||||
|
href: "/help/tutorials/designing-experiments",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Wizard Interface",
|
||||||
|
href: "/help/tutorials/wizard-interface",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>What is a Trial?</h2>
|
||||||
|
<p>A <strong>Trial</strong> is a single execution of an experiment with one participant:</p>
|
||||||
|
<pre><code>Trial
|
||||||
|
├── Participant (who took part)
|
||||||
|
├── Experiment (which protocol)
|
||||||
|
├── Status (scheduled, in_progress, completed)
|
||||||
|
├── Events (timestamped actions)
|
||||||
|
└── Data (collected responses)</code></pre>
|
||||||
|
|
||||||
|
<h2>Trial Lifecycle</h2>
|
||||||
|
<pre><code>Scheduled → In Progress → Completed
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Aborted ◄────────┤
|
||||||
|
│ │ │
|
||||||
|
└────────► Failed ◄───────┘</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 1: Schedule a Trial</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Go to your <strong>Study</strong></li>
|
||||||
|
<li>Open <strong>Trials</strong> tab</li>
|
||||||
|
<li>Click <strong>Schedule Trial</strong></li>
|
||||||
|
<li>Select:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Participant</strong>: P001</li>
|
||||||
|
<li><strong>Experiment</strong>: The Interactive Storyteller</li>
|
||||||
|
<li><strong>Scheduled Time</strong>: Today, 2:00 PM</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 2: Prepare for Trial</h2>
|
||||||
|
<p>Before starting:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Verify Robot Connection</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Check robot is powered on</li>
|
||||||
|
<li>Verify network connection</li>
|
||||||
|
<li>Test WebSocket connection</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Review Experiment</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Ensure experiment is "Ready" status</li>
|
||||||
|
<li>Check step count and timing</li>
|
||||||
|
<li>Verify all actions are configured</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Prepare Environment</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Ensure participant consent is obtained</li>
|
||||||
|
<li>Set up recording equipment (if needed)</li>
|
||||||
|
<li>Remove distractions</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 3: Start a Trial</h2>
|
||||||
|
<p>From Trials List:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Find the scheduled trial</li>
|
||||||
|
<li>Click <strong>Start Trial</strong></li>
|
||||||
|
<li>Confirm participant is ready</li>
|
||||||
|
<li>Click <strong>Begin</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 4: During the Trial</h2>
|
||||||
|
<p>The wizard interface provides:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Timeline View</strong> - Visual step progression</li>
|
||||||
|
<li><strong>Current Step</strong> - Highlighted current step</li>
|
||||||
|
<li><strong>Progress</strong> - Estimated time remaining</li>
|
||||||
|
<li><strong>Event Log</strong> - Timestamped events</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 5: Wizard Interventions</h2>
|
||||||
|
<p>During Wizard-of-Oz studies, wizards can intervene:</p>
|
||||||
|
|
||||||
|
<h3>Add Intervention</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>+ Intervention</strong></li>
|
||||||
|
<li>Select type:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Pause</strong>: Temporarily stop trial</li>
|
||||||
|
<li><strong>Resume</strong>: Continue after pause</li>
|
||||||
|
<li><strong>Note</strong>: Add observation</li>
|
||||||
|
<li><strong>Alert</strong>: Send alert notification</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Branch Selection</h3>
|
||||||
|
<p>When reaching a conditional step:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Observe participant response</li>
|
||||||
|
<li>Select appropriate branch</li>
|
||||||
|
<li>Selection is logged for analysis</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 6: Trial Completion</h2>
|
||||||
|
|
||||||
|
<h3>Automatic Completion</h3>
|
||||||
|
<p>When all steps complete:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Final step executes</li>
|
||||||
|
<li>Trial status → "Completed"</li>
|
||||||
|
<li>Data is saved automatically</li>
|
||||||
|
<li>Summary shown</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Manual Completion</h3>
|
||||||
|
<p>To end early:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Stop Trial</strong></li>
|
||||||
|
<li>Confirm completion</li>
|
||||||
|
<li>Select reason</li>
|
||||||
|
<li>Save partial data</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Best Practices</h2>
|
||||||
|
|
||||||
|
<h3>Before Trials</h3>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
<li>Robot connected and tested</li>
|
||||||
|
<li>Experiment verified</li>
|
||||||
|
<li>Participant consent obtained</li>
|
||||||
|
<li>Recording equipment ready</li>
|
||||||
|
<li>Wizard briefed on protocol</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>During Trials</h3>
|
||||||
|
<ul className="list-disc pl-6">
|
||||||
|
<li>Monitor timeline progress</li>
|
||||||
|
<li>Take timestamped notes</li>
|
||||||
|
<li>Document interventions</li>
|
||||||
|
<li>Watch for issues</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/designing-experiments">
|
||||||
|
Previous: Designing Experiments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/wizard-interface">
|
||||||
|
Next: Wizard Interface
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SimulationModeTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Simulation Mode"
|
||||||
|
description="Test experiments without a physical robot"
|
||||||
|
duration="10 min"
|
||||||
|
level="Beginner"
|
||||||
|
steps={[
|
||||||
|
{ title: "Enable simulation mode", description: "" },
|
||||||
|
{ title: "Test robot actions", description: "" },
|
||||||
|
{ title: "Run test trials", description: "" },
|
||||||
|
{ title: "Practice wizard controls", description: "" },
|
||||||
|
{ title: "Transition to real robot", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Data & Analysis",
|
||||||
|
href: "/help/tutorials/data-and-analysis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Why Simulation Mode?</h2>
|
||||||
|
<p>Simulation mode allows you to:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Test protocols</strong> without a robot</li>
|
||||||
|
<li><strong>Train wizards</strong> before live sessions</li>
|
||||||
|
<li><strong>Debug experiments</strong> in development</li>
|
||||||
|
<li><strong>Run pilots</strong> without robot access</li>
|
||||||
|
<li><strong>Develop</strong> on any computer</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Simulation Options</h2>
|
||||||
|
<p>HRIStudio offers two simulation approaches:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Approach</th><th>Pros</th><th>Cons</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Client-side</td>
|
||||||
|
<td>No server needed, instant</td>
|
||||||
|
<td>Limited robot simulation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Mock Server</td>
|
||||||
|
<td>Full rosbridge protocol</td>
|
||||||
|
<td>Requires running server</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 1: Enable Client-Side Simulation</h2>
|
||||||
|
|
||||||
|
<h3>Quick Start</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Create or edit <code>hristudio/.env.local</code></li>
|
||||||
|
<li>Add: <code>NEXT_PUBLIC_SIMULATION_MODE=true</code></li>
|
||||||
|
<li>Restart the dev server:
|
||||||
|
<pre><code>bun dev</code></pre>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Verify Enabled</h3>
|
||||||
|
<p>Look for the simulation indicator in the UI:</p>
|
||||||
|
<pre><code>Wizard Interface [🔵 SIMULATION MODE]</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 2: Start Mock Server (Optional)</h2>
|
||||||
|
<p>For more complete testing, use the mock server:</p>
|
||||||
|
|
||||||
|
<h3>Standalone Server</h3>
|
||||||
|
<pre><code>cd hristudio/scripts/mock-robot
|
||||||
|
bun install
|
||||||
|
bun dev</code></pre>
|
||||||
|
|
||||||
|
<h3>Docker</h3>
|
||||||
|
<pre><code>cd nao6-hristudio-integration
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.mock.yml --profile mock up -d</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 3: Test Robot Actions</h2>
|
||||||
|
|
||||||
|
<h3>From NAO Test Page</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Navigate to: <code>/nao-test</code></li>
|
||||||
|
<li>Click <strong>Connect</strong></li>
|
||||||
|
<li>Test actions:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Speech</strong> - Enter text, click Say</li>
|
||||||
|
<li><strong>Movement</strong> - Set speed, click Walk</li>
|
||||||
|
<li><strong>Head</strong> - Set angles, click Move</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Simulated Actions</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Action</th><th>Simulation Behavior</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>say_text</td><td>Duration = 1.5s + 300ms × word_count</td></tr>
|
||||||
|
<tr><td>walk_forward</td><td>Position updates over 500ms</td></tr>
|
||||||
|
<tr><td>turn_left/right</td><td>Angle changes over 500ms</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 4: Run Test Trials</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Enable simulation mode</li>
|
||||||
|
<li>Create or open experiment</li>
|
||||||
|
<li>Schedule trial</li>
|
||||||
|
<li>Start trial in wizard interface</li>
|
||||||
|
<li>Execute through all steps</li>
|
||||||
|
<li>Verify timing and flow</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Test Checklist</h3>
|
||||||
|
<ul>
|
||||||
|
<li>All steps execute in order</li>
|
||||||
|
<li>Branching decisions work</li>
|
||||||
|
<li>Timing estimates are accurate</li>
|
||||||
|
<li>Event log captures everything</li>
|
||||||
|
<li>No errors or warnings</li>
|
||||||
|
<li>Trial completes successfully</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 5: Training Wizards</h2>
|
||||||
|
<p>Simulation mode is perfect for training:</p>
|
||||||
|
|
||||||
|
<h3>Training Scenarios</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Basic Operation</strong> - Start/pause trials, execute actions</li>
|
||||||
|
<li><strong>Decision Making</strong> - Select appropriate branches</li>
|
||||||
|
<li><strong>Handling Issues</strong> - Pause, respond to alerts, stop early</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Transitioning to Real Robot</h2>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Disable Simulation</strong>
|
||||||
|
<pre><code>NEXT_PUBLIC_SIMULATION_MODE=false</code></pre>
|
||||||
|
</li>
|
||||||
|
<li><strong>Connect Robot</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Start Docker services</li>
|
||||||
|
<li>Verify robot connection</li>
|
||||||
|
<li>Test with NAO Test Page</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Run Comparison Trial</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Run same experiment on real robot</li>
|
||||||
|
<li>Compare timing and behavior</li>
|
||||||
|
<li>Adjust parameters as needed</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Comparison: Simulation vs Real</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Aspect</th><th>Simulation</th><th>Real Robot</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Setup time</td><td>1 min</td><td>30+ min</td></tr>
|
||||||
|
<tr><td>Availability</td><td>Always</td><td>Requires robot</td></tr>
|
||||||
|
<tr><td>Cost</td><td>Free</td><td>Robot access needed</td></tr>
|
||||||
|
<tr><td>Timing accuracy</td><td>Estimated</td><td>Actual</td></tr>
|
||||||
|
<tr><td>Physical interaction</td><td>✗</td><td>✓</td></tr>
|
||||||
|
<tr><td>Sensor accuracy</td><td>Fake</td><td>Real</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Best Practices</h2>
|
||||||
|
|
||||||
|
<h3>When to Use Simulation</h3>
|
||||||
|
<ul>
|
||||||
|
<li>During experiment design</li>
|
||||||
|
<li>While robot unavailable</li>
|
||||||
|
<li>For wizard training</li>
|
||||||
|
<li>For debugging protocols</li>
|
||||||
|
<li>For quick iteration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>When to Use Real Robot</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Final protocol validation</li>
|
||||||
|
<li>Timing accuracy critical</li>
|
||||||
|
<li>Physical interaction matters</li>
|
||||||
|
<li>Sensor data needed</li>
|
||||||
|
<li>Pre-study pilot</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-start">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/data-and-analysis">
|
||||||
|
Previous: Data & Analysis
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function WizardInterfaceTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Wizard Interface"
|
||||||
|
description="Real-time trial control and monitoring"
|
||||||
|
duration="15 min"
|
||||||
|
level="Intermediate"
|
||||||
|
steps={[
|
||||||
|
{ title: "Access the wizard interface", description: "" },
|
||||||
|
{ title: "Understand the layout", description: "" },
|
||||||
|
{ title: "Control robot actions", description: "" },
|
||||||
|
{ title: "Make branching decisions", description: "" },
|
||||||
|
{ title: "Handle interruptions", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Running Trials",
|
||||||
|
href: "/help/tutorials/running-trials",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Robot Integration",
|
||||||
|
href: "/help/tutorials/robot-integration",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>What is the Wizard Interface?</h2>
|
||||||
|
<p>The <strong>Wizard Interface</strong> is your control center during trials. It provides:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Real-time trial monitoring</li>
|
||||||
|
<li>Robot action controls</li>
|
||||||
|
<li>Decision-making tools</li>
|
||||||
|
<li>Intervention capabilities</li>
|
||||||
|
<li>Event logging</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 1: Accessing the Interface</h2>
|
||||||
|
|
||||||
|
<h3>Method 1: From Trials List</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Trials</strong> in sidebar</li>
|
||||||
|
<li>Find your scheduled trial</li>
|
||||||
|
<li>Click <strong>Open Wizard</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Method 2: Direct URL</h3>
|
||||||
|
<pre><code>{`/trials/{trialId}/wizard`}</code></pre>
|
||||||
|
|
||||||
|
<h3>Method 3: Trial Queue</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Wizard Queue</strong></li>
|
||||||
|
<li>See all pending trials</li>
|
||||||
|
<li>Click <strong>Start</strong> on any trial</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 2: Understanding the Layout</h2>
|
||||||
|
|
||||||
|
<h3>Left Panel: Trial Controls</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Control</th><th>Function</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Play/Pause</td><td>Start or pause trial</td></tr>
|
||||||
|
<tr><td>Stop</td><td>End trial early</td></tr>
|
||||||
|
<tr><td>Notes</td><td>Add timestamped observations</td></tr>
|
||||||
|
<tr><td>Alert</td><td>Send alert to researchers</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Center Panel: Timeline</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Visual Progress</strong> - See step progression</li>
|
||||||
|
<li><strong>Current Position</strong> - Highlighted current step</li>
|
||||||
|
<li><strong>Time Display</strong> - Elapsed and estimated remaining</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Right Panel: Robot Control</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Status Section</strong> - Connection, battery, position</li>
|
||||||
|
<li><strong>Action Section</strong> - Quick action buttons</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 3: Controlling the Robot</h2>
|
||||||
|
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
<p>Pre-configured robot actions:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Action</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Say Text</td><td>Make robot speak</td></tr>
|
||||||
|
<tr><td>Wave</td><td>Wave gesture</td></tr>
|
||||||
|
<tr><td>Look at Me</td><td>Turn head toward participant</td></tr>
|
||||||
|
<tr><td>Nod</td><td>Confirmation nod</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Custom Say Text</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Say Text</strong></li>
|
||||||
|
<li>Enter text in popup</li>
|
||||||
|
<li>Select options (speed, emotion)</li>
|
||||||
|
<li>Click <strong>Execute</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 4: Making Decisions</h2>
|
||||||
|
<p>When the experiment reaches a branching point:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Observe</strong> participant's actual response</li>
|
||||||
|
<li><strong>Consider</strong> protocol criteria</li>
|
||||||
|
<li><strong>Select</strong> appropriate branch</li>
|
||||||
|
<li><strong>Confirm</strong> selection</li>
|
||||||
|
</ol>
|
||||||
|
<p>Decision is logged with timestamp and trial continues.</p>
|
||||||
|
|
||||||
|
<h2>Step 5: Handling Interruptions</h2>
|
||||||
|
|
||||||
|
<h3>Pause Trial</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Pause</strong> button</li>
|
||||||
|
<li>Add reason (optional)</li>
|
||||||
|
<li>Trial pauses, robot holds position</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Resume Trial</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Play</strong> button</li>
|
||||||
|
<li>Trial resumes from pause point</li>
|
||||||
|
<li>Pause duration is logged</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Stop Trial</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>Stop</strong> button</li>
|
||||||
|
<li>Select reason</li>
|
||||||
|
<li>Confirm stop</li>
|
||||||
|
<li>Partial data is saved</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Key</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Space</td><td>Play/Pause toggle</td></tr>
|
||||||
|
<tr><td>Escape</td><td>Stop trial</td></tr>
|
||||||
|
<tr><td>N</td><td>Add note</td></tr>
|
||||||
|
<tr><td>A</td><td>Send alert</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Event Logging</h2>
|
||||||
|
<p>All actions are logged automatically:</p>
|
||||||
|
<pre><code>[14:32:05] Trial started
|
||||||
|
[14:32:08] Step 1: The Hook
|
||||||
|
[14:32:10] Action: Say Text "Hello!"
|
||||||
|
[14:33:28] Wizard Note: "Participant engaged"
|
||||||
|
[14:33:30] Branch: Correct selected
|
||||||
|
[14:34:05] Trial completed</code></pre>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/running-trials">
|
||||||
|
Previous: Running Trials
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/robot-integration">
|
||||||
|
Next: Robot Integration
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { TutorialPage } from "~/components/ui/tutorial-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function YourFirstStudyTutorial() {
|
||||||
|
return (
|
||||||
|
<TutorialPage
|
||||||
|
title="Your First Study"
|
||||||
|
description="Create a research study and manage team members"
|
||||||
|
duration="15 min"
|
||||||
|
level="Beginner"
|
||||||
|
steps={[
|
||||||
|
{ title: "Understand the Study structure", description: "" },
|
||||||
|
{ title: "Create a new study", description: "" },
|
||||||
|
{ title: "Add team members", description: "" },
|
||||||
|
{ title: "Install robot plugins", description: "" },
|
||||||
|
{ title: "Add participants", description: "" },
|
||||||
|
]}
|
||||||
|
prevTutorial={{
|
||||||
|
title: "Getting Started",
|
||||||
|
href: "/help/tutorials/getting-started",
|
||||||
|
}}
|
||||||
|
nextTutorial={{
|
||||||
|
title: "Designing Experiments",
|
||||||
|
href: "/help/tutorials/designing-experiments",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>What is a Study?</h2>
|
||||||
|
<p>In HRIStudio, a <strong>Study</strong> is the top-level container for your research:</p>
|
||||||
|
<pre><code>Study
|
||||||
|
├── Experiments (multiple protocols)
|
||||||
|
├── Participants (study participants)
|
||||||
|
├── Team Members (collaborators)
|
||||||
|
├── Forms & Surveys (consent, questionnaires)
|
||||||
|
└── Trials (individual experiment runs)</code></pre>
|
||||||
|
|
||||||
|
<h2>Step 1: Create a New Study</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Log in as <strong>Researcher</strong> or <strong>Administrator</strong></li>
|
||||||
|
<li>Click <strong>Studies</strong> in the sidebar</li>
|
||||||
|
<li>Click <strong>Create Study</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Study Settings</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>Study title</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Description</td>
|
||||||
|
<td>Brief overview of research goals</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Institution</td>
|
||||||
|
<td>University or organization</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>IRB Protocol</td>
|
||||||
|
<td>Protocol number (e.g., 2024-HRI-001)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Status</td>
|
||||||
|
<td>Draft, Active, Completed, Archived</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Step 2: Add Team Members</h2>
|
||||||
|
<p>Studies can have multiple collaborators with different roles:</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Owner</td>
|
||||||
|
<td>Full access, can delete study</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Researcher</td>
|
||||||
|
<td>Create/edit experiments, manage participants</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Wizard</td>
|
||||||
|
<td>Execute trials, control robot during trials</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Observer</td>
|
||||||
|
<td>View-only access, add annotations</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Adding a Wizard</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open your study</li>
|
||||||
|
<li>Go to <strong>Team</strong> tab</li>
|
||||||
|
<li>Click <strong>Add Member</strong></li>
|
||||||
|
<li>Enter the wizard's email</li>
|
||||||
|
<li>Select <strong>Wizard</strong> role</li>
|
||||||
|
<li>Click <strong>Invite</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 3: Install Robot Plugins</h2>
|
||||||
|
<p>For studies involving robots, you need to install the appropriate plugin:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Plugins</strong> in the sidebar</li>
|
||||||
|
<li>Select your study from the dropdown</li>
|
||||||
|
<li>Click <strong>Browse Plugins</strong></li>
|
||||||
|
<li>Find your robot (e.g., "NAO6 Robot")</li>
|
||||||
|
<li>Click <strong>Install</strong></li>
|
||||||
|
<li>Configure robot settings (IP address, etc.)</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Step 4: Add Participants</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>Participants</strong> tab</li>
|
||||||
|
<li>Click <strong>Add Participant</strong></li>
|
||||||
|
<li>Enter participant code (e.g., "P001")</li>
|
||||||
|
<li>Fill in optional details</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Batch Import</h3>
|
||||||
|
<p>For large studies, import from CSV:</p>
|
||||||
|
<pre><code>participantCode,name,email,notes
|
||||||
|
P001,John Smith,john@email.com,Condition A
|
||||||
|
P002,Jane Doe,jane@email.com,Condition B</code></pre>
|
||||||
|
|
||||||
|
<h2>Study Workflow</h2>
|
||||||
|
<pre><code>Draft → Active → Recruiting → In Progress → Completed
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └── All trials done
|
||||||
|
│ │ │ └── Trials running
|
||||||
|
│ │ └── Recruiting participants
|
||||||
|
│ └── Ready to collect data
|
||||||
|
└── Setting up study</code></pre>
|
||||||
|
|
||||||
|
<h2>Common Tasks</h2>
|
||||||
|
|
||||||
|
<h3>Clone a Study</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open the study</li>
|
||||||
|
<li>Click <strong>Settings</strong> (gear icon)</li>
|
||||||
|
<li>Select <strong>Duplicate Study</strong></li>
|
||||||
|
<li>Enter new study name</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Archive a Study</h3>
|
||||||
|
<p>When a study is complete:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Go to study settings</li>
|
||||||
|
<li>Change status to <strong>Archived</strong></li>
|
||||||
|
<li>Data is preserved but study is read-only</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/help/tutorials/getting-started">
|
||||||
|
Previous: Getting Started
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/help/tutorials/designing-experiments">
|
||||||
|
Next: Designing Experiments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TutorialPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,17 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Users,
|
Users,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Printer,
|
||||||
|
Pencil,
|
||||||
|
X,
|
||||||
|
FileDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -37,26 +41,11 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { FormField, FormFieldType } from "~/lib/types/forms";
|
||||||
interface Field {
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
id: string;
|
import { formStatusColors } from "~/lib/constants";
|
||||||
type: string;
|
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||||
label: string;
|
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||||
required: boolean;
|
|
||||||
options?: string[];
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldTypes = [
|
|
||||||
{ value: "text", label: "Text (short)", icon: "📝" },
|
|
||||||
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
|
||||||
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
|
||||||
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
|
||||||
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
|
||||||
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
|
||||||
{ value: "date", label: "Date", icon: "📅" },
|
|
||||||
{ value: "signature", label: "Signature", icon: "✍️" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formTypeIcons = {
|
const formTypeIcons = {
|
||||||
consent: FileSignature,
|
consent: FileSignature,
|
||||||
@@ -64,12 +53,6 @@ const formTypeIcons = {
|
|||||||
questionnaire: FileQuestion,
|
questionnaire: FileQuestion,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
pending: "bg-yellow-100 text-yellow-700",
|
|
||||||
completed: "bg-green-100 text-green-700",
|
|
||||||
rejected: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FormViewPageProps {
|
interface FormViewPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,12 +64,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [resolvedParams, setResolvedParams] = useState<{ id: string; formId: string } | null>(null);
|
const [resolvedParams, setResolvedParams] = useState<{
|
||||||
|
id: string;
|
||||||
|
formId: string;
|
||||||
|
} | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isEnteringData, setIsEnteringData] = useState(false);
|
||||||
|
const [selectedParticipantId, setSelectedParticipantId] =
|
||||||
|
useState<string>("");
|
||||||
|
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
|
||||||
|
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resolveParams = async () => {
|
const resolveParams = async () => {
|
||||||
@@ -96,6 +87,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
void resolveParams();
|
void resolveParams();
|
||||||
}, [params]);
|
}, [params]);
|
||||||
|
|
||||||
|
const { data: participants } = api.participants.list.useQuery(
|
||||||
|
{ studyId: resolvedParams?.id ?? "" },
|
||||||
|
{ enabled: !!resolvedParams?.id && isEnteringData },
|
||||||
|
);
|
||||||
|
|
||||||
const { data: study } = api.studies.get.useQuery(
|
const { data: study } = api.studies.get.useQuery(
|
||||||
{ id: resolvedParams?.id ?? "" },
|
{ id: resolvedParams?.id ?? "" },
|
||||||
{ enabled: !!resolvedParams?.id },
|
{ enabled: !!resolvedParams?.id },
|
||||||
@@ -125,11 +121,148 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitResponse = api.forms.submitResponse.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Response submitted successfully!");
|
||||||
|
setIsEnteringData(false);
|
||||||
|
setSelectedParticipantId("");
|
||||||
|
setFormResponses({});
|
||||||
|
void utils.forms.getResponses.invalidate({
|
||||||
|
formId: resolvedParams?.formId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to submit response", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportCsv = api.forms.exportCsv.useQuery(
|
||||||
|
{ formId: resolvedParams?.formId ?? "" },
|
||||||
|
{ enabled: !!resolvedParams?.formId && canManage },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (exportCsv.data) {
|
||||||
|
const blob = new Blob([exportCsv.data.csv], { type: "text/csv" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = exportCsv.data.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
toast.success("CSV exported successfully!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePdf = async () => {
|
||||||
|
if (!study || !form) return;
|
||||||
|
setIsGeneratingPdf(true);
|
||||||
|
const { downloadPdfFromHtml } = await import("~/lib/pdf-generator");
|
||||||
|
|
||||||
|
const fieldsHtml = fields
|
||||||
|
.map((field, index) => {
|
||||||
|
const requiredMark = field.required
|
||||||
|
? '<span style="color: red">*</span>'
|
||||||
|
: "";
|
||||||
|
let inputField = "";
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
inputField =
|
||||||
|
'<input type="text" style="width: 100%; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="________________________" />';
|
||||||
|
break;
|
||||||
|
case "textarea":
|
||||||
|
inputField =
|
||||||
|
'<textarea style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder=""></textarea>';
|
||||||
|
break;
|
||||||
|
case "multiple_choice":
|
||||||
|
inputField = `<div style="margin-top: 4px;">${field.options
|
||||||
|
?.map((opt) => `<div><input type="checkbox" /> ${opt}</div>`)
|
||||||
|
.join("")}</div>`;
|
||||||
|
break;
|
||||||
|
case "checkbox":
|
||||||
|
inputField =
|
||||||
|
'<div style="margin-top: 4px;"><input type="checkbox" /> Yes</div>';
|
||||||
|
break;
|
||||||
|
case "yes_no":
|
||||||
|
inputField =
|
||||||
|
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes <input type="radio" name="yn" /> No</div>';
|
||||||
|
break;
|
||||||
|
case "rating":
|
||||||
|
const scale = (field.settings?.scale as number) || 5;
|
||||||
|
inputField = `<div style="margin-top: 4px;">${Array.from(
|
||||||
|
{ length: scale },
|
||||||
|
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
|
||||||
|
).join("")}</div>`;
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
inputField =
|
||||||
|
'<input type="text" style="padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="MM/DD/YYYY" />';
|
||||||
|
break;
|
||||||
|
case "signature":
|
||||||
|
inputField =
|
||||||
|
'<div style="height: 60px; border: 1px solid #ccc; margin-top: 4px;"></div><div style="font-size: 12px; color: #666; margin-top: 4px;">Signature: _________________________ Date: ____________</div>';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<p style="margin: 0; font-weight: 500;">${index + 1}. ${field.label} ${requiredMark}</p>
|
||||||
|
${inputField}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join(
|
||||||
|
"<hr style='border: none; border-top: 1px solid #eee; margin: 16px 0;' />",
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h1 style="margin-bottom: 8px;">${title}</h1>
|
||||||
|
${description ? `<p style="color: #666; margin-bottom: 24px;">${description}</p>` : ""}
|
||||||
|
<p style="color: #666; font-size: 12px; margin-bottom: 24px;">
|
||||||
|
<strong>Study:</strong> ${study?.name || ""} |
|
||||||
|
<strong>Form Type:</strong> ${form?.type} |
|
||||||
|
<strong>Version:</strong> ${form?.version}
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 2px solid #333; margin-bottom: 24px;" />
|
||||||
|
${fieldsHtml}
|
||||||
|
<hr style="border: none; border-top: 2px solid #333; margin-top: 24px;" />
|
||||||
|
<p style="font-size: 10px; color: #999; margin-top: 24px;">
|
||||||
|
Generated by HRIStudio | ${new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await downloadPdfFromHtml(html, {
|
||||||
|
filename: `${title.replace(/\s+/g, "_")}_form.pdf`,
|
||||||
|
});
|
||||||
|
setIsGeneratingPdf(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDataEntry = () => {
|
||||||
|
if (!selectedParticipantId || !form) {
|
||||||
|
toast.error("Please select a participant");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const answers: Record<string, any> = {};
|
||||||
|
fields.forEach((field) => {
|
||||||
|
answers[field.id] = formResponses[field.id] ?? "";
|
||||||
|
});
|
||||||
|
submitResponse.mutate({
|
||||||
|
formId: form.id,
|
||||||
|
participantId: selectedParticipantId,
|
||||||
|
responses: answers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (form) {
|
if (form) {
|
||||||
setTitle(form.title);
|
setTitle(form.title);
|
||||||
setDescription(form.description || "");
|
setDescription(form.description || "");
|
||||||
setFields((form.fields as Field[]) || []);
|
setFields((form.fields as FormField[]) || []);
|
||||||
}
|
}
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
@@ -147,26 +280,28 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
|
|
||||||
if (isLoading || !form) return <div>Loading...</div>;
|
if (isLoading || !form) return <div>Loading...</div>;
|
||||||
|
|
||||||
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
const TypeIcon =
|
||||||
|
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
||||||
const responses = responsesData?.responses ?? [];
|
const responses = responsesData?.responses ?? [];
|
||||||
|
|
||||||
const addField = (type: string) => {
|
const addField = (type: string) => {
|
||||||
const newField: Field = {
|
const newField: FormField = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type,
|
type: type as FormFieldType,
|
||||||
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
|
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||||
required: false,
|
required: false,
|
||||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
options:
|
||||||
|
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||||
};
|
};
|
||||||
setFields([...fields, newField]);
|
setFields([...fields, newField]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeField = (id: string) => {
|
const removeField = (id: string) => {
|
||||||
setFields(fields.filter(f => f.id !== id));
|
setFields(fields.filter((f) => f.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateField = (id: string, updates: Partial<Field>) => {
|
const updateField = (id: string, updates: Partial<FormField>) => {
|
||||||
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -175,7 +310,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
settings: form.settings as Record<string, any>,
|
settings: form.settings as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,10 +326,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TypeIcon className="h-5 w-5 text-muted-foreground" />
|
<TypeIcon className="text-muted-foreground h-5 w-5" />
|
||||||
<h1 className="text-2xl font-bold">{form.title}</h1>
|
<h1 className="text-2xl font-bold">{form.title}</h1>
|
||||||
{form.active && (
|
{form.active && (
|
||||||
<Badge variant="default" className="text-xs">Active</Badge>
|
<Badge variant="default" className="text-xs">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm capitalize">
|
<p className="text-muted-foreground text-sm capitalize">
|
||||||
@@ -215,10 +352,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => setIsEditing(true)}>
|
<>
|
||||||
<Edit2 className="mr-2 h-4 w-4" />
|
<Button
|
||||||
Edit Form
|
variant="outline"
|
||||||
</Button>
|
onClick={generatePdf}
|
||||||
|
disabled={isGeneratingPdf}
|
||||||
|
>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
{isGeneratingPdf ? "Generating..." : "Print PDF"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsEditing(true)}>
|
||||||
|
<Edit2 className="mr-2 h-4 w-4" />
|
||||||
|
Edit Form
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -228,6 +375,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||||
|
{canManage && (
|
||||||
|
<TabsTrigger value="data-entry">Data Entry</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="responses">
|
<TabsTrigger value="responses">
|
||||||
Responses ({responses.length})
|
Responses ({responses.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -243,7 +393,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<SelectValue placeholder="Add field..." />
|
<SelectValue placeholder="Add field..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldTypes.map((type) => (
|
{FORM_FIELD_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<span className="mr-2">{type.icon}</span>
|
<span className="mr-2">{type.icon}</span>
|
||||||
{type.label}
|
{type.label}
|
||||||
@@ -254,7 +404,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||||
<FileText className="mb-2 h-8 w-8" />
|
<FileText className="mb-2 h-8 w-8" />
|
||||||
<p>No fields added yet</p>
|
<p>No fields added yet</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,18 +415,26 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
className="flex items-start gap-3 rounded-lg border p-4"
|
className="flex items-start gap-3 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
<div className="flex cursor-grab items-center text-muted-foreground">
|
<div className="text-muted-foreground flex cursor-grab items-center">
|
||||||
<GripVertical className="h-5 w-5" />
|
<GripVertical className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
|
{
|
||||||
{fieldTypes.find(f => f.value === field.type)?.label}
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
|
?.icon
|
||||||
|
}{" "}
|
||||||
|
{
|
||||||
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
|
?.label
|
||||||
|
}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Input
|
<Input
|
||||||
value={field.label}
|
value={field.label}
|
||||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
onChange={(e) =>
|
||||||
|
updateField(field.id, { label: e.target.value })
|
||||||
|
}
|
||||||
placeholder="Field label"
|
placeholder="Field label"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -284,7 +442,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.required}
|
checked={field.required}
|
||||||
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
onChange={(e) =>
|
||||||
|
updateField(field.id, {
|
||||||
|
required: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
Required
|
Required
|
||||||
@@ -294,13 +456,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Options</Label>
|
<Label className="text-xs">Options</Label>
|
||||||
{field.options?.map((opt, i) => (
|
{field.options?.map((opt, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={opt}
|
value={opt}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newOptions = [...(field.options || [])];
|
const newOptions = [
|
||||||
|
...(field.options || []),
|
||||||
|
];
|
||||||
newOptions[i] = e.target.value;
|
newOptions[i] = e.target.value;
|
||||||
updateField(field.id, { options: newOptions });
|
updateField(field.id, {
|
||||||
|
options: newOptions,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
placeholder={`Option ${i + 1}`}
|
placeholder={`Option ${i + 1}`}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
@@ -310,8 +479,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
const newOptions = field.options?.filter(
|
||||||
updateField(field.id, { options: newOptions });
|
(_, idx) => idx !== i,
|
||||||
|
);
|
||||||
|
updateField(field.id, {
|
||||||
|
options: newOptions,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -323,8 +496,13 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
|
const newOptions = [
|
||||||
updateField(field.id, { options: newOptions });
|
...(field.options || []),
|
||||||
|
`Option ${(field.options?.length || 0) + 1}`,
|
||||||
|
];
|
||||||
|
updateField(field.id, {
|
||||||
|
options: newOptions,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
@@ -339,7 +517,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeField(field.id)}
|
onClick={() => removeField(field.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="text-destructive h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -358,16 +536,23 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div key={field.id} className="flex items-center gap-3 rounded-lg border p-3">
|
<div
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs">
|
key={field.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-xs">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium">{field.label}</p>
|
<p className="font-medium">{field.label}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{fieldTypes.find(f => f.value === field.type)?.label}
|
{
|
||||||
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
|
?.label
|
||||||
|
}
|
||||||
{field.required && " • Required"}
|
{field.required && " • Required"}
|
||||||
{field.type === "multiple_choice" && ` • ${field.options?.length} options`}
|
{field.type === "multiple_choice" &&
|
||||||
|
` • ${field.options?.length} options`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +572,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-xl font-semibold">{title}</h2>
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
{description && <p className="text-muted-foreground">{description}</p>}
|
{description && (
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<p className="text-muted-foreground">No fields to preview</p>
|
<p className="text-muted-foreground">No fields to preview</p>
|
||||||
@@ -397,13 +584,18 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<div key={field.id} className="space-y-2">
|
<div key={field.id} className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
{index + 1}. {field.label}
|
{index + 1}. {field.label}
|
||||||
{field.required && <span className="text-destructive"> *</span>}
|
{field.required && (
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
{field.type === "text" && (
|
{field.type === "text" && (
|
||||||
<Input placeholder="Enter your response..." disabled />
|
<Input placeholder="Enter your response..." disabled />
|
||||||
)}
|
)}
|
||||||
{field.type === "textarea" && (
|
{field.type === "textarea" && (
|
||||||
<Textarea placeholder="Enter your response..." disabled />
|
<Textarea
|
||||||
|
placeholder="Enter your response..."
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{field.type === "multiple_choice" && (
|
{field.type === "multiple_choice" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -431,18 +623,24 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
)}
|
)}
|
||||||
{field.type === "rating" && (
|
{field.type === "rating" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{Array.from({ length: field.settings?.scale || 5 }, (_, i) => (
|
{Array.from(
|
||||||
<button key={i} type="button" className="h-8 w-8 rounded border disabled" disabled>
|
{ length: (field.settings?.scale as number) || 5 },
|
||||||
{i + 1}
|
(_, i) => (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="disabled h-8 w-8 rounded border"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{field.type === "date" && (
|
{field.type === "date" && <Input type="date" disabled />}
|
||||||
<Input type="date" disabled />
|
|
||||||
)}
|
|
||||||
{field.type === "signature" && (
|
{field.type === "signature" && (
|
||||||
<div className="h-24 rounded border bg-muted/50 flex items-center justify-center text-muted-foreground">
|
<div className="bg-muted/50 text-muted-foreground flex h-24 items-center justify-center rounded border">
|
||||||
Signature pad (disabled in preview)
|
Signature pad (disabled in preview)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -454,14 +652,263 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="responses">
|
<TabsContent value="data-entry">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Manual Data Entry</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEnteringData(!isEnteringData);
|
||||||
|
setSelectedParticipantId("");
|
||||||
|
setFormResponses({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEnteringData ? (
|
||||||
|
<>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Enter Data
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isEnteringData ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Select Participant</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedParticipantId}
|
||||||
|
onValueChange={setSelectedParticipantId}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a participant..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{participants?.participants?.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name || p.participantCode || p.email || p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedParticipantId && (
|
||||||
|
<div className="space-y-6 border-t pt-4">
|
||||||
|
<h3 className="font-semibold">Form Responses</h3>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{index + 1}. {field.label}
|
||||||
|
{field.required && (
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{field.type === "text" && (
|
||||||
|
<Input
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Enter response..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === "textarea" && (
|
||||||
|
<Textarea
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Enter response..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === "multiple_choice" && (
|
||||||
|
<Select
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an option..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.options?.map((opt, i) => (
|
||||||
|
<SelectItem key={i} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{field.type === "checkbox" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formResponses[field.id] || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span>Yes</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{field.type === "yes_no" && (
|
||||||
|
<Select
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="yes">Yes</SelectItem>
|
||||||
|
<SelectItem value="no">No</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{field.type === "rating" && (
|
||||||
|
<Select
|
||||||
|
value={String(formResponses[field.id] || "")}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: parseInt(val),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select rating..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from(
|
||||||
|
{ length: (field.settings?.scale as number) || 5 },
|
||||||
|
(_, i) => (
|
||||||
|
<SelectItem key={i} value={String(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{field.type === "date" && (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === "signature" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={formResponses[field.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormResponses({
|
||||||
|
...formResponses,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Type name as signature..."
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
By entering your name above, you confirm that
|
||||||
|
the information provided is accurate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEnteringData(false);
|
||||||
|
setSelectedParticipantId("");
|
||||||
|
setFormResponses({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDataEntry}
|
||||||
|
disabled={submitResponse.isPending}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{submitResponse.isPending
|
||||||
|
? "Saving..."
|
||||||
|
: "Save Response"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Pencil className="mb-2 h-8 w-8" />
|
||||||
|
<p>Manual data entry</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Enter responses directly for participants who completed the
|
||||||
|
form on paper
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="responses">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Form Responses</CardTitle>
|
<CardTitle>Form Responses</CardTitle>
|
||||||
|
{canManage && responses.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
disabled={exportCsv.isFetching}
|
||||||
|
>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
{exportCsv.isFetching ? "Exporting..." : "Export CSV"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{responses.length === 0 ? (
|
{responses.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Users className="mb-2 h-8 w-8" />
|
<Users className="mb-2 h-8 w-8" />
|
||||||
<p>No responses yet</p>
|
<p>No responses yet</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,27 +916,35 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{responses.map((response) => (
|
{responses.map((response) => (
|
||||||
<div key={response.id} className="rounded-lg border p-4">
|
<div key={response.id} className="rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{response.participant?.name || response.participant?.participantCode || "Unknown"}
|
{response.participant?.name ||
|
||||||
|
response.participant?.participantCode ||
|
||||||
|
"Unknown"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}>
|
<Badge
|
||||||
|
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
|
||||||
|
>
|
||||||
{response.status}
|
{response.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{Object.entries(response.responses as Record<string, any>).map(([key, value]) => (
|
{Object.entries(
|
||||||
|
response.responses as Record<string, any>,
|
||||||
|
).map(([key, value]) => (
|
||||||
<div key={key} className="flex gap-2">
|
<div key={key} className="flex gap-2">
|
||||||
<span className="text-muted-foreground">{key}:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{key}:
|
||||||
|
</span>
|
||||||
<span>{String(value)}</span>
|
<span>{String(value)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{response.signedAt && (
|
{response.signedAt && (
|
||||||
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-2 border-t pt-2 text-xs">
|
||||||
Signed: {new Date(response.signedAt).toLocaleString()}
|
Signed: {new Date(response.signedAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -503,4 +958,4 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSession } from "~/lib/auth-client";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -8,22 +8,17 @@ import Link from "next/link";
|
|||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Save,
|
||||||
Trash2,
|
LayoutTemplate,
|
||||||
GripVertical,
|
|
||||||
FileSignature,
|
FileSignature,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
Save,
|
|
||||||
Copy,
|
|
||||||
LayoutTemplate,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -31,29 +26,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { FormField, FormType } from "~/lib/types/forms";
|
||||||
interface Field {
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
id: string;
|
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||||
type: string;
|
|
||||||
label: string;
|
|
||||||
required: boolean;
|
|
||||||
options?: string[];
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldTypes = [
|
|
||||||
{ value: "text", label: "Text (short)", icon: "📝" },
|
|
||||||
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
|
||||||
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
|
||||||
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
|
||||||
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
|
||||||
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
|
||||||
{ value: "date", label: "Date", icon: "📅" },
|
|
||||||
{ value: "signature", label: "Signature", icon: "✍️" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formTypes = [
|
const formTypes = [
|
||||||
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
|
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
|
||||||
@@ -65,14 +42,13 @@ export default function NewFormPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const studyId = typeof params.id === "string" ? params.id : "";
|
const studyId = typeof params.id === "string" ? params.id : "";
|
||||||
|
|
||||||
const [formType, setFormType] = useState<string>("");
|
const [formType, setFormType] = useState<string>("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const { data: study } = api.studies.get.useQuery(
|
const { data: study } = api.studies.get.useQuery(
|
||||||
@@ -115,25 +91,6 @@ export default function NewFormPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const addField = (type: string) => {
|
|
||||||
const newField: Field = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type,
|
|
||||||
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
|
|
||||||
required: false,
|
|
||||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
|
||||||
};
|
|
||||||
setFields([...fields, newField]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeField = (id: string) => {
|
|
||||||
setFields(fields.filter(f => f.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (id: string, updates: Partial<Field>) => {
|
|
||||||
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -145,7 +102,7 @@ export default function NewFormPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
createForm.mutate({
|
createForm.mutate({
|
||||||
studyId,
|
studyId,
|
||||||
type: formType as "consent" | "survey" | "questionnaire",
|
type: formType as FormType,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
@@ -266,12 +223,21 @@ export default function NewFormPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Form Fields</CardTitle>
|
<CardTitle>Form Fields</CardTitle>
|
||||||
<Select onValueChange={addField}>
|
<Select onValueChange={(type) => {
|
||||||
|
const newField: FormField = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: type as FormField["type"],
|
||||||
|
label: `New ${FORM_FIELD_TYPES.find(f => f.value === type)?.label || "Field"}`,
|
||||||
|
required: false,
|
||||||
|
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||||
|
};
|
||||||
|
setFields([...fields, newField]);
|
||||||
|
}}>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="w-[200px]">
|
||||||
<SelectValue placeholder="Add field..." />
|
<SelectValue placeholder="Add field..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldTypes.map((type) => (
|
{FORM_FIELD_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<span className="mr-2">{type.icon}</span>
|
<span className="mr-2">{type.icon}</span>
|
||||||
{type.label}
|
{type.label}
|
||||||
@@ -281,117 +247,7 @@ export default function NewFormPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{fields.length === 0 ? (
|
<FormBuilder fields={fields} onFieldsChange={setFields} />
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
|
||||||
<FileText className="mb-2 h-8 w-8" />
|
|
||||||
<p>No fields added yet</p>
|
|
||||||
<p className="text-sm">Use the dropdown above to add fields</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<div
|
|
||||||
key={field.id}
|
|
||||||
className="flex items-start gap-3 rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex cursor-grab items-center text-muted-foreground">
|
|
||||||
<GripVertical className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
|
|
||||||
{fieldTypes.find(f => f.value === field.type)?.label}
|
|
||||||
</Badge>
|
|
||||||
<Input
|
|
||||||
value={field.label}
|
|
||||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
|
||||||
placeholder="Field label"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.required}
|
|
||||||
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
Required
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{field.type === "multiple_choice" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Options</Label>
|
|
||||||
{field.options?.map((opt, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={opt}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newOptions = [...(field.options || [])];
|
|
||||||
newOptions[i] = e.target.value;
|
|
||||||
updateField(field.id, { options: newOptions });
|
|
||||||
}}
|
|
||||||
placeholder={`Option ${i + 1}`}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
|
||||||
updateField(field.id, { options: newOptions });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
|
|
||||||
updateField(field.id, { options: newOptions });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
|
||||||
Add Option
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{field.type === "rating" && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Scale:</span>
|
|
||||||
<Select
|
|
||||||
value={field.settings?.scale?.toString() || "5"}
|
|
||||||
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[100px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="5">1-5</SelectItem>
|
|
||||||
<SelectItem value="7">1-7</SelectItem>
|
|
||||||
<SelectItem value="10">1-10</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeField(field.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -407,4 +263,4 @@ export default function NewFormPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
FileQuestion,
|
FileQuestion,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Eye,
|
Eye,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -40,9 +39,11 @@ const formTypeIcons = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formTypeColors = {
|
const formTypeColors = {
|
||||||
consent: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
consent:
|
||||||
|
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
||||||
survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
questionnaire: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
questionnaire:
|
||||||
|
"bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StudyFormsPageProps {
|
interface StudyFormsPageProps {
|
||||||
@@ -55,7 +56,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,10 +137,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
|
|
||||||
{forms.length === 0 && !isLoading ? (
|
{forms.length === 0 && !isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
<FileText className="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
<h3 className="text-lg font-semibold mb-2">No Forms Yet</h3>
|
<h3 className="mb-2 text-lg font-semibold">No Forms Yet</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Create consent forms, surveys, or questionnaires to collect data from participants
|
Create consent forms, surveys, or questionnaires to collect data
|
||||||
|
from participants
|
||||||
</p>
|
</p>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
@@ -151,8 +155,8 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative max-w-sm flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search forms..."
|
placeholder="Search forms..."
|
||||||
value={search}
|
value={search}
|
||||||
@@ -164,8 +168,12 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{forms.map((form) => {
|
{forms.map((form) => {
|
||||||
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
const TypeIcon =
|
||||||
const typeColor = formTypeColors[form.type as keyof typeof formTypeColors] || "bg-gray-100";
|
formTypeIcons[form.type as keyof typeof formTypeIcons] ||
|
||||||
|
FileText;
|
||||||
|
const typeColor =
|
||||||
|
formTypeColors[form.type as keyof typeof formTypeColors] ||
|
||||||
|
"bg-gray-100";
|
||||||
const isActive = form.active;
|
const isActive = form.active;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -177,7 +185,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
<TypeIcon className="h-4 w-4" />
|
<TypeIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">{form.title}</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
{form.title}
|
||||||
|
</CardTitle>
|
||||||
<p className="text-muted-foreground text-xs capitalize">
|
<p className="text-muted-foreground text-xs capitalize">
|
||||||
{form.type}
|
{form.type}
|
||||||
</p>
|
</p>
|
||||||
@@ -192,18 +202,22 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-3">
|
<CardContent className="pb-3">
|
||||||
{form.description && (
|
{form.description && (
|
||||||
<p className="text-muted-foreground text-sm line-clamp-2 mb-3">
|
<p className="text-muted-foreground mb-3 line-clamp-2 text-sm">
|
||||||
{form.description}
|
{form.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||||
<span>v{form.version}</span>
|
<span>v{form.version}</span>
|
||||||
<span>{(form as any)._count?.responses ?? 0} responses</span>
|
<span>
|
||||||
|
{(form as any)._count?.responses ?? 0} responses
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<div className="flex items-center justify-between border-t bg-muted/30 px-4 py-2">
|
<div className="bg-muted/30 flex items-center justify-between border-t px-4 py-2">
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}`}>
|
<Link
|
||||||
|
href={`/studies/${resolvedParams?.id}/forms/${form.id}`}
|
||||||
|
>
|
||||||
<Eye className="mr-1 h-3 w-3" />
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
@@ -216,15 +230,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}/edit`}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setActiveMutation.mutate({ id: form.id })}
|
onClick={() =>
|
||||||
|
setActiveMutation.mutate({ id: form.id })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
Set Active
|
Set Active
|
||||||
@@ -232,7 +242,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm("Are you sure you want to delete this form?")) {
|
if (
|
||||||
|
confirm(
|
||||||
|
"Are you sure you want to delete this form?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
deleteMutation.mutate({ id: form.id });
|
deleteMutation.mutate({ id: form.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -253,4 +267,4 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
FileSignature,
|
||||||
|
ClipboardList,
|
||||||
|
FileQuestion,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { FormField } from "~/lib/types/forms";
|
||||||
|
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||||
|
|
||||||
|
const formTypeIcons = {
|
||||||
|
consent: FileSignature,
|
||||||
|
survey: ClipboardList,
|
||||||
|
questionnaire: FileQuestion,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ParticipantFormPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const formId = params.formId as string;
|
||||||
|
|
||||||
|
const [participantCode, setParticipantCode] = useState("");
|
||||||
|
const [formResponses, setFormResponses] = useState<Record<string, unknown>>({});
|
||||||
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const { data: form, isLoading: formLoading } = api.forms.getPublic.useQuery(
|
||||||
|
{ id: formId },
|
||||||
|
{ enabled: !!formId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitResponse = api.forms.submitPublic.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Response submitted successfully!");
|
||||||
|
setHasSubmitted(true);
|
||||||
|
},
|
||||||
|
onError: (error: { message: string }) => {
|
||||||
|
toast.error("Submission failed", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
if (code) {
|
||||||
|
setParticipantCode(code);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
if (formLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h1 className="text-2xl font-bold">Form Not Found</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
This form may have been removed or the link is invalid.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSubmitted) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<div className="mb-4 rounded-full bg-green-100 p-4">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-green-600">Thank You!</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-md">
|
||||||
|
Your response has been submitted successfully.
|
||||||
|
{form.type === "consent" && " Please proceed with your session."}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-6" asChild>
|
||||||
|
<Link href="/">Return Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TypeIcon =
|
||||||
|
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
||||||
|
const fields = (form.fields as FormField[]) || [];
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
if (field.required) {
|
||||||
|
const value = formResponses[field.id];
|
||||||
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === "" ||
|
||||||
|
(typeof value === "string" && value.trim() === "")
|
||||||
|
) {
|
||||||
|
errors[field.id] = "This field is required";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!participantCode.trim()) {
|
||||||
|
toast.error("Please enter your participant code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitResponse.mutate({
|
||||||
|
formId,
|
||||||
|
participantCode: participantCode.trim(),
|
||||||
|
responses: formResponses,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponse = (fieldId: string, value: unknown) => {
|
||||||
|
setFormResponses({ ...formResponses, [fieldId]: value });
|
||||||
|
if (fieldErrors[fieldId]) {
|
||||||
|
const newErrors = { ...fieldErrors };
|
||||||
|
delete newErrors[fieldId];
|
||||||
|
setFieldErrors(newErrors);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen py-8">
|
||||||
|
<div className="mx-auto max-w-2xl px-4">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="bg-primary/10 mb-4 inline-flex rounded-full p-3">
|
||||||
|
<TypeIcon className="text-primary h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold">{form.title}</h1>
|
||||||
|
{form.description && (
|
||||||
|
<p className="text-muted-foreground mt-3 text-lg">
|
||||||
|
{form.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{form.type === "consent"
|
||||||
|
? "Consent Form"
|
||||||
|
: form.type === "survey"
|
||||||
|
? "Survey"
|
||||||
|
: "Questionnaire"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fields marked with <span className="text-destructive">*</span> are
|
||||||
|
required
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="participantCode">
|
||||||
|
Participant Code <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="participantCode"
|
||||||
|
value={participantCode}
|
||||||
|
onChange={(e) => setParticipantCode(e.target.value)}
|
||||||
|
placeholder="Enter your participant code (e.g., P001)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Enter the participant code provided by the researcher
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="mb-6 last:mb-0">
|
||||||
|
<FormFieldLabel
|
||||||
|
field={field}
|
||||||
|
index={index}
|
||||||
|
showIndex
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<FormFieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={formResponses[field.id]}
|
||||||
|
onChange={(val) => updateResponse(field.id, val)}
|
||||||
|
mode="participant"
|
||||||
|
index={index}
|
||||||
|
error={fieldErrors[field.id]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldErrors[field.id] && (
|
||||||
|
<p className="text-destructive mt-1 text-sm">
|
||||||
|
{fieldErrors[field.id]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={submitResponse.isPending}
|
||||||
|
>
|
||||||
|
{submitResponse.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Submit Response
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Powered by HRIStudio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFieldLabel({
|
||||||
|
field,
|
||||||
|
index,
|
||||||
|
showIndex = true,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
field: FormField;
|
||||||
|
index: number;
|
||||||
|
showIndex?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
className={error ? "text-destructive" : ""}
|
||||||
|
>
|
||||||
|
{showIndex && `${index + 1}. `}
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,27 +21,27 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, studyId, robotId, parameters } = body;
|
const { action, studyId, robotId, parameters } = body;
|
||||||
|
|
||||||
// Verify user has access to the study
|
|
||||||
const membership = await db.query.studyMembers.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(studyMembers.studyId, studyId),
|
|
||||||
eq(studyMembers.userId, session.user.id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Insufficient permissions" },
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const robotIp =
|
const robotIp =
|
||||||
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
||||||
const password = process.env.NAO_PASSWORD || "robolab";
|
const password = process.env.NAO_PASSWORD || "robolab";
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "initialize": {
|
case "initialize": {
|
||||||
|
// Requires study membership
|
||||||
|
const membership = await db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Insufficient permissions" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Robots API] Initializing robot at ${robotIp}`);
|
console.log(`[Robots API] Initializing robot at ${robotIp}`);
|
||||||
|
|
||||||
const disableAlCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; al = naoqi.ALProxy('ALAutonomousLife', '127.0.0.1', 9559); al.setState('disabled')\\""`;
|
const disableAlCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; al = naoqi.ALProxy('ALAutonomousLife', '127.0.0.1', 9559); al.setState('disabled')\\""`;
|
||||||
@@ -58,6 +58,21 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "executeSystemAction": {
|
case "executeSystemAction": {
|
||||||
|
// Requires study membership
|
||||||
|
const membership = await db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, session.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Insufficient permissions" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { id, parameters: actionParams } = parameters ?? {};
|
const { id, parameters: actionParams } = parameters ?? {};
|
||||||
console.log(`[Robots API] Executing system action ${id}`);
|
console.log(`[Robots API] Executing system action ${id}`);
|
||||||
|
|
||||||
@@ -89,6 +104,50 @@ export async function POST(request: NextRequest) {
|
|||||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "play_animation_bow":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/BowShort_1'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_hey":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Hey_1'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_show_floor":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/ShowFloor_1'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_enthusiastic":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Enthusiastic_4'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_yes":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Yes_1'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_no":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/No_3'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "play_animation_idontknow":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/IDontKnow_1'"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stand":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Stand 0.5"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stand_init":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture StandInit 0.5"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "sit":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Sit 0.5"`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "crouch":
|
||||||
|
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Crouch 0.5"`;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `System action ${id} not implemented` },
|
{ error: `System action ${id} not implemented` },
|
||||||
@@ -100,6 +159,37 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "executeSSH": {
|
||||||
|
// Session auth is sufficient — no studyId needed
|
||||||
|
// command may be top-level in body or nested under parameters
|
||||||
|
const { command } = parameters ?? body;
|
||||||
|
if (!command) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing command parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Robots API] Executing SSH command: ${command}`);
|
||||||
|
|
||||||
|
const sshCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(sshCmd);
|
||||||
|
if (stderr && !stderr.includes("null") && stderr.trim()) {
|
||||||
|
console.warn(`[Robots API] SSH stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
console.log(`[Robots API] SSH result: ${stdout}`);
|
||||||
|
return NextResponse.json({ success: true, stdout, stderr });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Robots API] SSH command failed:`, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "SSH command failed" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Unknown action: ${action}` },
|
{ error: `Unknown action: ${action}` },
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ export default function SignInPage() {
|
|||||||
id="not-robot"
|
id="not-robot"
|
||||||
checked={notRobot}
|
checked={notRobot}
|
||||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="not-robot"
|
htmlFor="not-robot"
|
||||||
|
|||||||
+329
-187
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -8,18 +9,41 @@ import { Logo } from "~/components/ui/logo";
|
|||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Beaker,
|
|
||||||
Bot,
|
Bot,
|
||||||
Database,
|
Database,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Lock,
|
Lock,
|
||||||
Network,
|
Network,
|
||||||
PlayCircle,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
Share2,
|
Share2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
Beaker,
|
||||||
|
FileText,
|
||||||
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const screenshots = [
|
||||||
|
{
|
||||||
|
src: "/images/screenshots/experiment-designer.png",
|
||||||
|
alt: "Visual Experiment Designer",
|
||||||
|
label: "Design",
|
||||||
|
className: "md:col-span-2 md:row-span-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/screenshots/wizard-interface.png",
|
||||||
|
alt: "Wizard Execution Interface",
|
||||||
|
label: "Execute",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/screenshots/dashboard.png",
|
||||||
|
alt: "Study Dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
@@ -40,7 +64,7 @@ export default async function Home() {
|
|||||||
<Link href="#features">Features</Link>
|
<Link href="#features">Features</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
||||||
<Link href="#architecture">Architecture</Link>
|
<Link href="#how-it-works">How It Works</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
@@ -55,8 +79,7 @@ export default async function Home() {
|
|||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
|
<section className="relative overflow-hidden pt-20 pb-24 md:pt-32">
|
||||||
{/* Background Gradients */}
|
|
||||||
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||||
|
|
||||||
<div className="container mx-auto flex flex-col items-center px-4 text-center">
|
<div className="container mx-auto flex flex-col items-center px-4 text-center">
|
||||||
@@ -65,26 +88,27 @@ export default async function Home() {
|
|||||||
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
|
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
|
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
|
||||||
The Modern Standard for HRI Research
|
Open Source WoZ Platform
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
||||||
Reproducible WoZ Studies <br className="hidden md:block" />
|
Wizard-of-Oz Studies <br className="hidden md:block" />
|
||||||
<span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400">
|
<span className="bg-gradient-to-r from-cyan-500 to-blue-600 bg-clip-text text-transparent">
|
||||||
Made Simple
|
Made Scientific
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
|
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
|
||||||
HRIStudio is the open-source platform that bridges the gap between
|
HRIStudio is the open-source platform that makes human-robot
|
||||||
ease of use and scientific rigor. Design, execute, and analyze
|
interaction research reproducible, accessible, and collaborative.
|
||||||
human-robot interaction experiments with zero friction.
|
Design experiments, control robots, and analyze results — all in
|
||||||
|
one place.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||||
<Button size="lg" className="h-12 px-8 text-base" asChild>
|
<Button size="lg" className="h-12 px-8 text-base" asChild>
|
||||||
<Link href="/auth/signup">
|
<Link href="/auth/signup">
|
||||||
Start Researching
|
Start Your Research
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -102,127 +126,160 @@ export default async function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Mockup / Visual Interest */}
|
{/* Screenshots Section */}
|
||||||
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
<section id="screenshots" className="container mx-auto px-4 py-12">
|
||||||
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" />
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
|
{screenshots.map((screenshot, index) => (
|
||||||
{/* Placeholder for actual app screenshot */}
|
<div
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
|
key={index}
|
||||||
<div className="p-8 text-center">
|
className={`group bg-muted/50 relative overflow-hidden rounded-xl border ${screenshot.className}`}
|
||||||
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
|
>
|
||||||
<p className="text-muted-foreground font-medium">
|
{/* Placeholder - replace src with actual screenshot */}
|
||||||
Interactive Experiment Designer
|
<div className="from-muted to-muted/50 absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br">
|
||||||
|
<div className="bg-background/80 mb-4 rounded-lg px-4 py-2 shadow-sm">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||||
|
{screenshot.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FileText className="text-muted-foreground/30 h-16 w-16" />
|
||||||
|
<p className="text-muted-foreground/50 mt-4 text-sm">
|
||||||
|
Screenshot: {screenshot.alt}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground/30 mt-1 text-xs">
|
||||||
|
Replace with actual image
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Uncomment when you have real screenshots:
|
||||||
|
<Image
|
||||||
|
src={screenshot.src}
|
||||||
|
alt={screenshot.alt}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-4 text-center text-sm">
|
||||||
|
Add screenshots to{" "}
|
||||||
|
<code className="bg-muted rounded px-2 py-1">
|
||||||
|
public/images/screenshots/
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section id="features" className="bg-muted/30 border-t py-24">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="mb-16 text-center">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
|
Built for Scientific Rigor
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
||||||
|
Everything you need to conduct reproducible Wizard-of-Oz
|
||||||
|
studies, from experiment design to data analysis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<FeatureCard
|
||||||
|
icon={LayoutTemplate}
|
||||||
|
title="Visual Experiment Designer"
|
||||||
|
description="Build complex branching narratives with drag-and-drop blocks. No coding required — just drag, configure, and run."
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={PlayCircle}
|
||||||
|
title="Guided Wizard Interface"
|
||||||
|
description="Step-by-step protocol execution keeps wizards on track. Every action is logged with timestamps."
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Bot}
|
||||||
|
title="Robot Agnostic"
|
||||||
|
description="Design experiments once, run on any robot. NAO, Pepper, TurtleBot — your logic stays the same."
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Users}
|
||||||
|
title="Role-Based Collaboration"
|
||||||
|
description="Invite PIs, wizards, and observers. Each role sees exactly what they need — nothing more."
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Database}
|
||||||
|
title="Automatic Data Logging"
|
||||||
|
description="Every action, timestamp, and sensor reading is captured. Export to CSV for analysis."
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
icon={Lock}
|
||||||
|
title="Built-in Reproducibility"
|
||||||
|
description="Protocol/trial separation, deviation logging, and comprehensive audit trails make replication trivial."
|
||||||
|
color="cyan"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Bento Grid */}
|
{/* How It Works */}
|
||||||
<section id="features" className="container mx-auto px-4 py-24">
|
<section id="how-it-works" className="container mx-auto px-4 py-24">
|
||||||
<div className="mb-12 text-center">
|
<div className="mb-16 text-center">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
Everything You Need
|
How It Works
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-4 text-lg">
|
<p className="text-muted-foreground mt-4 text-lg">
|
||||||
Built for the specific needs of HRI researchers and wizards.
|
From design to publication in one unified workflow.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
|
<div className="relative">
|
||||||
{/* Visual Designer - Large Item */}
|
{/* Connection line */}
|
||||||
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10">
|
<div className="bg-border absolute top-0 left-1/2 hidden h-full w-px -translate-x-1/2 lg:block" />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<LayoutTemplate className="h-5 w-5 text-blue-500" />
|
|
||||||
Visual Experiment Designer
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1">
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Construct complex branching narratives without writing a
|
|
||||||
single line of code. Our node-based editor handles logic,
|
|
||||||
timing, and robot actions automatically.
|
|
||||||
</p>
|
|
||||||
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
||||||
<span className="bg-accent rounded p-2">Start</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
|
|
||||||
Robot: Greet
|
|
||||||
</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
<span className="bg-accent rounded p-2">Wait: 5s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Robot Agnostic */}
|
<div className="space-y-12 lg:space-y-0">
|
||||||
<Card className="col-span-1 md:col-span-1 lg:col-span-2">
|
<WorkflowStep
|
||||||
<CardHeader>
|
number={1}
|
||||||
<CardTitle className="flex items-center gap-2">
|
title="Design"
|
||||||
<Bot className="h-5 w-5 text-green-500" />
|
description="Use the visual editor to build your experiment protocol with drag-and-drop blocks. Add speech, gestures, conditions, and branching logic — no code required."
|
||||||
Robot Agnostic
|
icon={LayoutTemplate}
|
||||||
</CardTitle>
|
/>
|
||||||
</CardHeader>
|
<WorkflowStep
|
||||||
<CardContent>
|
number={2}
|
||||||
<p className="text-muted-foreground">
|
title="Configure"
|
||||||
Switch between robots instantly. Whether it's a NAO, Pepper,
|
description="Set up your study, invite team members with appropriate roles, and configure your robot platform."
|
||||||
or a custom ROS2 bot, your experiment logic remains strictly
|
icon={Settings2}
|
||||||
separated from hardware implementation.
|
/>
|
||||||
</p>
|
<WorkflowStep
|
||||||
</CardContent>
|
number={3}
|
||||||
</Card>
|
title="Execute"
|
||||||
|
description="Run trials with the wizard interface. Real-time updates keep everyone in sync. Every action is automatically logged."
|
||||||
{/* Role Based */}
|
icon={PlayCircle}
|
||||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
/>
|
||||||
<CardHeader>
|
<WorkflowStep
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
number={4}
|
||||||
<Lock className="h-4 w-4 text-orange-500" />
|
title="Analyze"
|
||||||
Role-Based Access
|
description="Review trial data, export responses, and compare across participants. Everything is timestamped and synchronized."
|
||||||
</CardTitle>
|
icon={Share2}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Granular permissions for Principal Investigators, Wizards, and
|
|
||||||
Observers.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Data Logging */}
|
|
||||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Database className="h-4 w-4 text-rose-500" />
|
|
||||||
Full Traceability
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Every wizard action, automated response, and sensor reading is
|
|
||||||
time-stamped and logged.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Architecture Section */}
|
{/* Architecture Section */}
|
||||||
<section id="architecture" className="bg-muted/30 border-t py-24">
|
<section id="architecture" className="bg-muted/30 border-t py-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8">
|
<div className="grid items-center gap-12 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
Enterprise-Grade Architecture
|
Modern Architecture
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-4 text-lg">
|
<p className="text-muted-foreground mt-4 text-lg">
|
||||||
Designed for reliability and scale. HRIStudio uses a modern
|
Built on proven technologies for reliability, type safety, and
|
||||||
stack to ensure your data is safe and your experiments run
|
real-time collaboration.
|
||||||
smoothly.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
@@ -232,9 +289,9 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">3-Layer Design</h3>
|
<h3 className="font-semibold">3-Layer Design</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Clear separation between UI, Data, and Hardware layers
|
UI, application logic, and hardware layers are strictly
|
||||||
for maximum stability.
|
separated for stability.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,72 +300,74 @@ export default async function Home() {
|
|||||||
<Share2 className="text-primary h-5 w-5" />
|
<Share2 className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">Real-Time Sync</h3>
|
||||||
Collaborative by Default
|
<p className="text-muted-foreground text-sm">
|
||||||
</h3>
|
WebSocket updates keep wizard and observer views
|
||||||
<p className="text-muted-foreground">
|
perfectly synchronized.
|
||||||
Real-time state synchronization allows multiple
|
|
||||||
researchers to monitor a single trial.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||||
<Settings2 className="text-primary h-5 w-5" />
|
<Beaker className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">ROS2 Integration</h3>
|
<h3 className="font-semibold">Plugin System</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Native support for ROS2 nodes, topics, and actions right
|
Extend with custom robot integrations and actions
|
||||||
out of the box.
|
through a simple JSON configuration.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto w-full max-w-[500px]">
|
<div className="relative space-y-4">
|
||||||
{/* Abstract representation of architecture */}
|
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||||
<div className="relative z-10 space-y-4">
|
<CardHeader className="pb-2">
|
||||||
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
|
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||||
<CardHeader className="pb-2">
|
APP LAYER
|
||||||
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
</CardTitle>
|
||||||
APP LAYER
|
</CardHeader>
|
||||||
</CardTitle>
|
<CardContent>
|
||||||
</CardHeader>
|
<p className="text-sm font-medium">
|
||||||
<CardContent>
|
Next.js + React + tRPC
|
||||||
<p className="font-semibold">
|
</p>
|
||||||
Next.js Dashboard + Experiment Designer
|
<p className="text-muted-foreground text-xs">
|
||||||
</p>
|
Type-safe full-stack
|
||||||
</CardContent>
|
</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
|
</Card>
|
||||||
<CardHeader className="pb-2">
|
<Card className="border-violet-500/20 bg-violet-500/5">
|
||||||
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
<CardHeader className="pb-2">
|
||||||
DATA LAYER
|
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
||||||
</CardTitle>
|
DATA LAYER
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<p className="font-semibold">
|
<CardContent>
|
||||||
PostgreSQL + MinIO + TRPC API
|
<p className="text-sm font-medium">
|
||||||
</p>
|
PostgreSQL + MinIO + WebSocket
|
||||||
</CardContent>
|
</p>
|
||||||
</Card>
|
<p className="text-muted-foreground text-xs">
|
||||||
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
|
Persistent storage + real-time
|
||||||
<CardHeader className="pb-2">
|
</p>
|
||||||
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
</CardContent>
|
||||||
HARDWARE LAYER
|
</Card>
|
||||||
</CardTitle>
|
<Card className="border-green-500/20 bg-green-500/5">
|
||||||
</CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardContent>
|
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
||||||
<p className="font-semibold">
|
ROBOT LAYER
|
||||||
ROS2 Bridge + Robot Plugins
|
</CardTitle>
|
||||||
</p>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<p className="text-sm font-medium">
|
||||||
</div>
|
ROS2 Bridge + Plugin Config
|
||||||
{/* Decorative blobs */}
|
</p>
|
||||||
<div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Platform agnostic
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,21 +375,33 @@ export default async function Home() {
|
|||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="container mx-auto px-4 py-24 text-center">
|
<section className="container mx-auto px-4 py-24 text-center">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
Ready to upgrade your lab?
|
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
</h2>
|
Ready to upgrade your research?
|
||||||
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
</h2>
|
||||||
Join the community of researchers building the future of HRI with
|
<p className="text-muted-foreground mt-4 text-lg">
|
||||||
reproducible, open-source tools.
|
Join researchers building reproducible HRI studies with
|
||||||
</p>
|
open-source tools.
|
||||||
<div className="mt-8">
|
</p>
|
||||||
<Button
|
<div className="mt-8 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||||
size="lg"
|
<Button
|
||||||
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
size="lg"
|
||||||
asChild
|
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
||||||
>
|
asChild
|
||||||
<Link href="/auth/signup">Get Started for Free</Link>
|
>
|
||||||
</Button>
|
<Link href="/auth/signup">Get Started Free</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 px-8 text-base"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/docs" target="_blank">
|
||||||
|
Read the Docs
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -340,25 +411,96 @@ export default async function Home() {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Logo iconSize="sm" showText={true} />
|
<Logo iconSize="sm" showText={true} />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
© {new Date().getFullYear()} HRIStudio. Open source under MIT
|
||||||
|
License.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex gap-6 text-sm">
|
<div className="text-muted-foreground flex gap-6 text-sm">
|
||||||
|
<Link href="/docs" className="hover:text-foreground">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://github.com/robolab/hristudio"
|
||||||
|
className="hover:text-foreground"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
<Link href="#" className="hover:text-foreground">
|
<Link href="#" className="hover:text-foreground">
|
||||||
Privacy
|
Privacy
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="#" className="hover:text-foreground">
|
<Link href="#" className="hover:text-foreground">
|
||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="#" className="hover:text-foreground">
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
<Link href="#" className="hover:text-foreground">
|
|
||||||
Documentation
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FeatureCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
color: "blue" | "violet" | "green" | "orange" | "rose" | "cyan";
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
blue: "text-blue-500 bg-blue-500/10",
|
||||||
|
violet: "text-violet-500 bg-violet-500/10",
|
||||||
|
green: "text-green-500 bg-green-500/10",
|
||||||
|
orange: "text-orange-500 bg-orange-500/10",
|
||||||
|
rose: "text-rose-500 bg-rose-500/10",
|
||||||
|
cyan: "text-cyan-500 bg-cyan-500/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div
|
||||||
|
className={`mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg ${colors[color]}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowStep({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-center gap-4 lg:flex-row lg:gap-8">
|
||||||
|
<div className="border-primary bg-background text-primary z-10 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 font-bold">
|
||||||
|
{number}
|
||||||
|
</div>
|
||||||
|
<Card className="flex-1">
|
||||||
|
<CardHeader className="flex flex-row items-center gap-4 pb-2">
|
||||||
|
<Icon className="text-primary h-5 w-5" />
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
// Define error type for mutations
|
// Define error type for mutations
|
||||||
interface TRPCError {
|
interface TRPCError {
|
||||||
@@ -101,131 +100,37 @@ const syncStatusConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function RepositoryActionsCell({ repository }: { repository: Repository }) {
|
function RepositoryUrlCell({ url }: { url: string }) {
|
||||||
const utils = api.useUtils();
|
const handleCopy = () => {
|
||||||
|
void navigator.clipboard.writeText(url);
|
||||||
const syncMutation = api.admin.repositories.sync.useMutation({
|
toast.success("URL copied to clipboard");
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Repository sync started");
|
|
||||||
void utils.admin.repositories.list.invalidate();
|
|
||||||
},
|
|
||||||
onError: (error: TRPCError) => {
|
|
||||||
toast.error(error.message ?? "Failed to sync repository");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = api.admin.repositories.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Repository deleted successfully");
|
|
||||||
void utils.admin.repositories.list.invalidate();
|
|
||||||
},
|
|
||||||
onError: (error: TRPCError) => {
|
|
||||||
toast.error(error.message ?? "Failed to delete repository");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
syncMutation.mutate({ id: repository.id });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (
|
|
||||||
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
|
|
||||||
) {
|
|
||||||
deleteMutation.mutate({ id: repository.id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyId = () => {
|
|
||||||
void navigator.clipboard.writeText(repository.id);
|
|
||||||
toast.success("Repository ID copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyUrl = () => {
|
|
||||||
void navigator.clipboard.writeText(repository.url);
|
|
||||||
toast.success("Repository URL copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
const canDelete = !repository.isOfficial;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="ghost" size="icon" onClick={handleCopy}>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Copy className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
</Button>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
{url && (
|
||||||
</Button>
|
<Link
|
||||||
</DropdownMenuTrigger>
|
href={url}
|
||||||
<DropdownMenuContent align="end">
|
target="_blank"
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
rel="noopener noreferrer"
|
||||||
<DropdownMenuSeparator />
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={syncMutation.isPending}
|
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
Visit
|
||||||
/>
|
</Link>
|
||||||
Sync Repository
|
)}
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/repositories/${repository.id}/edit`}>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
Edit Repository
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href={repository.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
|
||||||
View Repository
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleCopyId}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Copy Repository ID
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleCopyUrl}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Copy Repository URL
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Repository
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ column }) => (
|
||||||
<Checkbox
|
<DataTableColumnHeader column={column} title="#" />
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -240,34 +145,16 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Repository Name" />
|
<DataTableColumnHeader column={column} title="Repository" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const repository = row.original;
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center space-x-2">
|
<span className="font-medium">{row.original.name}</span>
|
||||||
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
{row.original.description && (
|
||||||
<Link
|
<span className="text-muted-foreground text-xs">
|
||||||
href={`/admin/repositories/${repository.id}`}
|
{row.original.description}
|
||||||
className="truncate font-medium hover:underline"
|
</span>
|
||||||
title={repository.name}
|
|
||||||
>
|
|
||||||
{repository.name}
|
|
||||||
</Link>
|
|
||||||
{repository.isOfficial && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Official
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{repository.description && (
|
|
||||||
<p
|
|
||||||
className="text-muted-foreground line-clamp-1 truncate text-sm"
|
|
||||||
title={repository.description}
|
|
||||||
>
|
|
||||||
{repository.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -276,22 +163,11 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "url",
|
accessorKey: "url",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Repository URL" />
|
<DataTableColumnHeader column={column} title="URL" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<RepositoryUrlCell url={row.original.url} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
|
||||||
const url = row.original.url;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
|
|
||||||
title={url}
|
|
||||||
>
|
|
||||||
{url}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "trustLevel",
|
accessorKey: "trustLevel",
|
||||||
@@ -327,25 +203,15 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
const isEnabled = row.original.isEnabled;
|
const isEnabled = row.original.isEnabled;
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant={isEnabled ? "default" : "secondary"}
|
||||||
className={
|
className={isEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
|
||||||
isEnabled
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-red-100 text-red-800"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEnabled ? (
|
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="mr-1 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{isEnabled ? "Enabled" : "Disabled"}
|
{isEnabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value: string[]) => {
|
filterFn: (row, id, value: string[]) => {
|
||||||
const isEnabled = row.original.isEnabled;
|
return value.includes(row.original.isEnabled ? "enabled" : "disabled");
|
||||||
return value.includes(isEnabled ? "enabled" : "disabled");
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -354,80 +220,97 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
<DataTableColumnHeader column={column} title="Sync Status" />
|
<DataTableColumnHeader column={column} title="Sync Status" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const syncStatus = row.original.syncStatus;
|
const status = row.original.syncStatus || "pending";
|
||||||
const lastSyncAt = row.original.lastSyncAt;
|
const config = syncStatusConfig[status as keyof typeof syncStatusConfig];
|
||||||
const syncError = row.original.syncError;
|
const StatusIcon = config?.icon ?? Clock;
|
||||||
|
|
||||||
if (!syncStatus) return "-";
|
|
||||||
|
|
||||||
const config =
|
|
||||||
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
|
|
||||||
if (!config) return syncStatus;
|
|
||||||
|
|
||||||
const SyncIcon = config.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
{config && (
|
||||||
variant="secondary"
|
<Badge variant="secondary" className={config.className}>
|
||||||
className={config.className}
|
<StatusIcon className="mr-1 h-3 w-3" />
|
||||||
title={config.description}
|
{config.label}
|
||||||
>
|
</Badge>
|
||||||
<SyncIcon
|
|
||||||
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
{config.label}
|
|
||||||
</Badge>
|
|
||||||
{lastSyncAt && syncStatus === "completed" && (
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{syncError && syncStatus === "failed" && (
|
{row.original.syncError && (
|
||||||
<div
|
<Button
|
||||||
className="max-w-[150px] truncate text-xs text-red-600"
|
variant="ghost"
|
||||||
title={syncError}
|
size="sm"
|
||||||
|
className="h-auto whitespace-normal text-xs text-destructive"
|
||||||
|
title={row.original.syncError}
|
||||||
>
|
>
|
||||||
{syncError}
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||||
</div>
|
Error
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
filterFn: (row, id, value: string[]) => {
|
||||||
{
|
const status = row.original.syncStatus || "pending";
|
||||||
accessorKey: "createdAt",
|
return value.includes(status);
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title="Created" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const date = row.original.createdAt;
|
|
||||||
return (
|
|
||||||
<div className="text-sm whitespace-nowrap">
|
|
||||||
{formatDistanceToNow(date, { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "updatedAt",
|
accessorKey: "lastSyncAt",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Updated" />
|
<DataTableColumnHeader column={column} title="Last Sync" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = row.original.updatedAt;
|
const lastSync = row.original.lastSyncAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm">
|
||||||
{formatDistanceToNow(date, { addSuffix: true })}
|
{lastSync ? formatDistanceToNow(lastSync, { addSuffix: true }) : "Never"}
|
||||||
</div>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
sortingFn: "datetime",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
|
const repository = row.original;
|
||||||
enableSorting: false,
|
return (
|
||||||
enableHiding: false,
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigator.clipboard.writeText(repository.id)}
|
||||||
|
>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/repositories/${repository.id}`}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
asChild
|
||||||
|
disabled={!repository.url}
|
||||||
|
>
|
||||||
|
<Link href={repository.url ?? "#"} target="_blank">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Visit Repository
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{!repository.isOfficial && (
|
||||||
|
<DropdownMenuItem className="text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Repository
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import { formatBytes } from "~/lib/utils";
|
||||||
|
|
||||||
export function SystemStats() {
|
export function SystemStats() {
|
||||||
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
||||||
@@ -25,14 +26,6 @@ export function SystemStats() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
const formatUptime = (seconds: number) => {
|
||||||
const d = Math.floor(seconds / (3600 * 24));
|
const d = Math.floor(seconds / (3600 * 24));
|
||||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Building,
|
Building,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
GraduationCap,
|
||||||
Home,
|
Home,
|
||||||
LogOut,
|
LogOut,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useSidebar } from "~/components/ui/sidebar";
|
import { useSidebar } from "~/components/ui/sidebar";
|
||||||
@@ -59,7 +61,6 @@ import { Logo } from "~/components/ui/logo";
|
|||||||
|
|
||||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
// Global items - always available
|
// Global items - always available
|
||||||
const globalItems = [
|
const globalItems = [
|
||||||
@@ -129,10 +130,9 @@ const helpItems = [
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Interactive Tour",
|
title: "Tutorials",
|
||||||
url: "#tour",
|
url: "/help/tutorials",
|
||||||
icon: PlayCircle,
|
icon: PlayCircle,
|
||||||
action: "tour",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export function AppSidebar({
|
|||||||
isLoadingUserStudies,
|
isLoadingUserStudies,
|
||||||
} = useStudyManagement();
|
} = useStudyManagement();
|
||||||
|
|
||||||
const { startTour, isTourActive } = useTour();
|
const { startTour, stopTour, isTourActive } = useTour();
|
||||||
|
|
||||||
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||||
const hasAutoSelected = useRef(false);
|
const hasAutoSelected = useRef(false);
|
||||||
@@ -183,12 +183,6 @@ export function AppSidebar({
|
|||||||
}
|
}
|
||||||
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
|
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
|
||||||
|
|
||||||
// Debug API call
|
|
||||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
|
||||||
enabled: process.env.NODE_ENV === "development",
|
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
type Study = {
|
type Study = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -285,9 +279,6 @@ export function AppSidebar({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [refreshStudyData]);
|
}, [refreshStudyData]);
|
||||||
|
|
||||||
// Show debug info in development
|
|
||||||
const showDebug = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -318,12 +309,21 @@ export function AppSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{isTourActive && !isCollapsed && (
|
{isTourActive && !isCollapsed && (
|
||||||
<div className="mt-1 px-3 pb-2">
|
<div className="mt-1 px-3 pb-2">
|
||||||
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
||||||
<span className="relative flex h-2 w-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||||
</span>
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||||
Tutorial Active
|
</span>
|
||||||
|
Tutorial Active
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={stopTour}
|
||||||
|
className="text-primary/60 hover:text-primary hover:bg-primary/10 rounded p-0.5 transition-colors"
|
||||||
|
title="Cancel tutorial"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -600,23 +600,14 @@ export function AppSidebar({
|
|||||||
{helpItems.map((item) => {
|
{helpItems.map((item) => {
|
||||||
const isActive = pathname.startsWith(item.url);
|
const isActive = pathname.startsWith(item.url);
|
||||||
|
|
||||||
const menuButton =
|
const menuButton = (
|
||||||
item.action === "tour" ? (
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
<SidebarMenuButton
|
<Link href={item.url}>
|
||||||
onClick={() => startTour("full_platform")}
|
|
||||||
isActive={false}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</SidebarMenuButton>
|
</Link>
|
||||||
) : (
|
</SidebarMenuButton>
|
||||||
<SidebarMenuButton asChild isActive={isActive}>
|
);
|
||||||
<Link href={item.url}>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
@@ -639,99 +630,8 @@ export function AppSidebar({
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
{/* Debug info moved to footer tooltip button */}
|
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{showDebug && (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-xs"
|
|
||||||
aria-label="Debug info"
|
|
||||||
>
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
className="space-y-1 p-2 text-[10px]"
|
|
||||||
>
|
|
||||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
|
||||||
<div>Role: {userRole ?? "No role"}</div>
|
|
||||||
<div>Studies: {userStudies.length}</div>
|
|
||||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
|
||||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
|
||||||
{debugData && (
|
|
||||||
<>
|
|
||||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
|
||||||
<div>
|
|
||||||
System Roles:{" "}
|
|
||||||
{debugData.systemRoles.join(", ") || "None"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Memberships: {debugData.studyMemberships.length}
|
|
||||||
</div>
|
|
||||||
<div>All Studies: {debugData.allStudies.length}</div>
|
|
||||||
<div>
|
|
||||||
Session ID: {debugData.session.userId.slice(0, 8)}
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton className="w-full justify-start">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
<span className="truncate">Debug</span>
|
|
||||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-[--radix-popper-anchor-width] max-w-72"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="text-xs font-medium">
|
|
||||||
Debug Info
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="space-y-1 px-2 py-1 text-[11px] leading-tight">
|
|
||||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
|
||||||
<div>Role: {userRole ?? "No role"}</div>
|
|
||||||
<div>Studies: {userStudies.length}</div>
|
|
||||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
|
||||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
|
||||||
{debugData && (
|
|
||||||
<>
|
|
||||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
|
||||||
<div>
|
|
||||||
System Roles:{" "}
|
|
||||||
{debugData.systemRoles.join(", ") || "None"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Memberships: {debugData.studyMemberships.length}
|
|
||||||
</div>
|
|
||||||
<div>All Studies: {debugData.allStudies.length}</div>
|
|
||||||
<div>
|
|
||||||
Session ID: {debugData.session.userId.slice(0, 8)}
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)}
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const experimentSchema = z.object({
|
|||||||
.max(100, "Name too long"),
|
.max(100, "Name too long"),
|
||||||
description: z
|
description: z
|
||||||
.string()
|
.string()
|
||||||
.min(10, "Description must be at least 10 characters")
|
.max(1000, "Description too long")
|
||||||
.max(1000, "Description too long"),
|
.optional(),
|
||||||
studyId: z.string().uuid("Please select a study"),
|
studyId: z.string().uuid("Please select a study"),
|
||||||
estimatedDuration: z
|
estimatedDuration: z
|
||||||
.number()
|
.number()
|
||||||
@@ -123,7 +123,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
|||||||
if (mode === "edit" && experiment) {
|
if (mode === "edit" && experiment) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: experiment.name,
|
name: experiment.name,
|
||||||
description: experiment.description ?? "",
|
description: experiment.description ?? undefined,
|
||||||
studyId: experiment.studyId,
|
studyId: experiment.studyId,
|
||||||
estimatedDuration: experiment.estimatedDuration ?? undefined,
|
estimatedDuration: experiment.estimatedDuration ?? undefined,
|
||||||
status: experiment.status,
|
status: experiment.status,
|
||||||
|
|||||||
@@ -1079,14 +1079,18 @@ export function DesignerRoot({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defExec = actionDef.execution as any;
|
||||||
const execution: ExperimentAction["execution"] =
|
const execution: ExperimentAction["execution"] =
|
||||||
actionDef.execution &&
|
defExec &&
|
||||||
(actionDef.execution.transport === "internal" ||
|
(defExec.transport === "internal" ||
|
||||||
actionDef.execution.transport === "rest" ||
|
defExec.transport === "rest" ||
|
||||||
actionDef.execution.transport === "ros2")
|
defExec.transport === "ros2")
|
||||||
? {
|
? {
|
||||||
transport: actionDef.execution.transport,
|
transport: defExec.transport,
|
||||||
retryable: actionDef.execution.retryable ?? false,
|
retryable: defExec.retryable ?? false,
|
||||||
|
timeoutMs: defExec.timeoutMs,
|
||||||
|
ros2: defExec.ros2,
|
||||||
|
rest: defExec.rest,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
PlayCircle,
|
||||||
|
Square,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PropertiesPanel
|
* PropertiesPanel
|
||||||
@@ -90,6 +97,10 @@ export function PropertiesPanelBase({
|
|||||||
const [localStepDescription, setLocalStepDescription] = useState("");
|
const [localStepDescription, setLocalStepDescription] = useState("");
|
||||||
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// Test action state
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
||||||
|
|
||||||
// Debounce timers
|
// Debounce timers
|
||||||
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
@@ -168,6 +179,74 @@ export function PropertiesPanelBase({
|
|||||||
selectedAction &&
|
selectedAction &&
|
||||||
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
||||||
|
|
||||||
|
// Test action handler
|
||||||
|
const handleTestAction = useCallback(async () => {
|
||||||
|
if (!selectedAction || !containingStep) return;
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus("running");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type);
|
||||||
|
console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2));
|
||||||
|
console.log("[Test Action] Parameters:", selectedAction.parameters);
|
||||||
|
|
||||||
|
// Reset service to ensure clean state for testing
|
||||||
|
resetWizardRosService();
|
||||||
|
|
||||||
|
// Initialize with actual robot connection (not simulation)
|
||||||
|
const rosService = await initWizardRosService(false);
|
||||||
|
console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus());
|
||||||
|
|
||||||
|
// Build action config from execution descriptor
|
||||||
|
const execution = selectedAction.execution;
|
||||||
|
let actionConfig: {
|
||||||
|
topic: string;
|
||||||
|
messageType: string;
|
||||||
|
payloadMapping: {
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
transformFn?: string;
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (execution?.transport === "ros2" && execution.ros2) {
|
||||||
|
const ros2 = execution.ros2 as any;
|
||||||
|
actionConfig = {
|
||||||
|
topic: ros2.topic || "/speech",
|
||||||
|
messageType: ros2.messageType || "std_msgs/msg/String",
|
||||||
|
payloadMapping: {
|
||||||
|
type: ros2.payloadMapping?.type || "static",
|
||||||
|
payload: ros2.payloadMapping?.payload,
|
||||||
|
transformFn: ros2.payloadMapping?.transformFn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the action on the real robot
|
||||||
|
const result = await rosService.executeRobotAction(
|
||||||
|
selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core",
|
||||||
|
selectedAction.type,
|
||||||
|
selectedAction.parameters,
|
||||||
|
actionConfig,
|
||||||
|
);
|
||||||
|
console.log("[Test Action] Execution result:", result);
|
||||||
|
|
||||||
|
setTestStatus("success");
|
||||||
|
toast.success(`Action "${selectedAction.name}" executed on robot`);
|
||||||
|
} catch (error) {
|
||||||
|
setTestStatus("error");
|
||||||
|
const message = error instanceof Error ? error.message : "Action execution failed";
|
||||||
|
toast.error(message);
|
||||||
|
console.error("Test action error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
// Reset status after a delay
|
||||||
|
setTimeout(() => setTestStatus("idle"), 2000);
|
||||||
|
}
|
||||||
|
}, [selectedAction, containingStep]);
|
||||||
|
|
||||||
/* -------------------------- Action Properties View -------------------------- */
|
/* -------------------------- Action Properties View -------------------------- */
|
||||||
if (selectedAction && containingStep) {
|
if (selectedAction && containingStep) {
|
||||||
let def = registry.getAction(selectedAction.type);
|
let def = registry.getAction(selectedAction.type);
|
||||||
@@ -277,6 +356,41 @@ export function PropertiesPanelBase({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Test Action Button */}
|
||||||
|
{selectedAction.execution?.transport !== "internal" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-1.5"
|
||||||
|
onClick={handleTestAction}
|
||||||
|
disabled={isTesting}
|
||||||
|
>
|
||||||
|
{testStatus === "running" ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : testStatus === "success" ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
Success!
|
||||||
|
</>
|
||||||
|
) : testStatus === "error" ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
Failed
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlayCircle className="h-4 w-4" />
|
||||||
|
Test Action
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* General */}
|
{/* General */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
@@ -721,6 +835,40 @@ export function PropertiesPanelBase({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">After this step, go to</Label>
|
||||||
|
<p className="text-muted-foreground mb-1 text-[10px]">
|
||||||
|
Override the next step (use to converge branch paths).
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={(selectedStep.trigger.conditions as any)?.nextStepId ?? "__linear__"}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
onStepUpdate(selectedStep.id, {
|
||||||
|
trigger: {
|
||||||
|
...selectedStep.trigger,
|
||||||
|
conditions: {
|
||||||
|
...(selectedStep.trigger.conditions as any),
|
||||||
|
nextStepId: val === "__linear__" ? undefined : val,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||||
|
<SelectValue placeholder="Next step (default)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__linear__">Next step (default)</SelectItem>
|
||||||
|
{design.steps
|
||||||
|
.filter((s) => s.id !== selectedStep.id)
|
||||||
|
.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -803,12 +951,13 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
|
|
||||||
if (param.type === "text") {
|
if (param.type === "text") {
|
||||||
control = (
|
control = (
|
||||||
<Input
|
<textarea
|
||||||
value={(localValue as string) ?? ""}
|
value={(localValue as string) ?? ""}
|
||||||
placeholder={param.placeholder}
|
placeholder={param.placeholder}
|
||||||
onChange={(e) => handleUpdate(e.target.value)}
|
onChange={(e) => handleUpdate(e.target.value)}
|
||||||
onBlur={handleCommit}
|
onBlur={handleCommit}
|
||||||
className="mt-1 h-7 w-full text-xs"
|
rows={3}
|
||||||
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (param.type === "select") {
|
} else if (param.type === "select") {
|
||||||
@@ -868,14 +1017,24 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={[Number(numericVal)]}
|
value={[Number(numericVal)]}
|
||||||
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
|
onValueChange={(vals) => setLocalValue(vals[0])}
|
||||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
onPointerUp={() => handleUpdate(localValue)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={Number(numericVal)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
if (!isNaN(v)) {
|
||||||
|
setLocalValue(Math.max(min, Math.min(max, v)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleCommit}
|
||||||
|
className="h-7 w-16 text-xs tabular-nums"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
|
||||||
{step < 1
|
|
||||||
? Number(numericVal).toFixed(2)
|
|
||||||
: Number(numericVal).toString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||||
<span>{min}</span>
|
<span>{min}</span>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Trash2, Plus, GripVertical } from "lucide-react";
|
||||||
|
import type { FormField, FormFieldType } from "~/lib/types/forms";
|
||||||
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
|
|
||||||
|
interface FormBuilderProps {
|
||||||
|
fields: FormField[];
|
||||||
|
onFieldsChange: (fields: FormField[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormBuilder({ fields, onFieldsChange, disabled = false }: FormBuilderProps) {
|
||||||
|
const addField = (type: string) => {
|
||||||
|
const newField: FormField = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: type as FormFieldType,
|
||||||
|
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||||
|
required: false,
|
||||||
|
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||||
|
};
|
||||||
|
onFieldsChange([...fields, newField]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (id: string) => {
|
||||||
|
onFieldsChange(fields.filter((f) => f.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (id: string, updates: Partial<FormField>) => {
|
||||||
|
onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<p>No fields added yet</p>
|
||||||
|
<p className="text-sm">Use the dropdown below to add fields</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
fields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="flex items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex cursor-grab items-center text-muted-foreground">
|
||||||
|
<GripVertical className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.icon}{" "}
|
||||||
|
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.label}
|
||||||
|
</Badge>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||||
|
placeholder="Field label"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.required}
|
||||||
|
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{field.type === "multiple_choice" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Options</Label>
|
||||||
|
{field.options?.map((opt, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOptions = [...(field.options || [])];
|
||||||
|
newOptions[i] = e.target.value;
|
||||||
|
updateField(field.id, { options: newOptions });
|
||||||
|
}}
|
||||||
|
placeholder={`Option ${i + 1}`}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
||||||
|
updateField(field.id, { options: newOptions });
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newOptions = [
|
||||||
|
...(field.options || []),
|
||||||
|
`Option ${(field.options?.length || 0) + 1}`,
|
||||||
|
];
|
||||||
|
updateField(field.id, { options: newOptions });
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Add Option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{field.type === "rating" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Scale:</span>
|
||||||
|
<Select
|
||||||
|
value={String(field.settings?.scale || 5)}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
updateField(field.id, { settings: { scale: parseInt(val) } })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">1-5</SelectItem>
|
||||||
|
<SelectItem value="7">1-7</SelectItem>
|
||||||
|
<SelectItem value="10">1-10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeField(field.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import type { FormField } from "~/lib/types/forms";
|
||||||
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
|
|
||||||
|
interface FormFieldRendererProps {
|
||||||
|
field: FormField;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
mode: "preview" | "data-entry" | "participant";
|
||||||
|
index: number;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldRenderer({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
mode,
|
||||||
|
index,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
}: FormFieldRendererProps) {
|
||||||
|
const handleChange = (val: unknown) => {
|
||||||
|
if (!disabled) {
|
||||||
|
onChange(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
disabled,
|
||||||
|
className: error ? "border-destructive" : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const scale = (field.settings?.scale as number) || 5;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="Enter your response..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...commonProps}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="Enter your response..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "multiple_choice": {
|
||||||
|
const containerClass =
|
||||||
|
mode === "participant"
|
||||||
|
? `mt-2 space-y-2 ${error ? "border-destructive rounded-md border p-2" : ""}`
|
||||||
|
: "space-y-2";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
{field.options?.map((opt, i) => (
|
||||||
|
<label key={i} className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.id}
|
||||||
|
value={opt}
|
||||||
|
checked={value === opt}
|
||||||
|
onChange={() => handleChange(opt)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(e) => handleChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
{mode === "participant" && (
|
||||||
|
<Label className="cursor-pointer font-normal">Yes, I agree</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "yes_no":
|
||||||
|
if (mode === "data-entry") {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onValueChange={(val) => handleChange(val)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="yes">Yes</SelectItem>
|
||||||
|
<SelectItem value="no">No</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.id}
|
||||||
|
value="yes"
|
||||||
|
checked={value === "yes"}
|
||||||
|
onChange={() => handleChange("yes")}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Yes</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.id}
|
||||||
|
value="no"
|
||||||
|
checked={value === "no"}
|
||||||
|
onChange={() => handleChange("no")}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">No</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "rating": {
|
||||||
|
const scale = field.settings?.scale || 5;
|
||||||
|
if (mode === "data-entry") {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onValueChange={(val) => handleChange(parseInt(val))}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select rating..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: scale }, (_, i) => (
|
||||||
|
<SelectItem key={i} value={String(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "participant") {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{Array.from({ length: scale }, (_, i) => (
|
||||||
|
<label key={i} className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.id}
|
||||||
|
value={String(i + 1)}
|
||||||
|
checked={value === i + 1}
|
||||||
|
onChange={() => handleChange(i + 1)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="peer sr-only"
|
||||||
|
/>
|
||||||
|
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Array.from({ length: scale }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="disabled h-8 w-8 rounded border"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
{...commonProps}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "signature":
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === "participant"
|
||||||
|
? "Type your full name as signature"
|
||||||
|
: "Type name as signature..."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
By entering your name above, you confirm that the information
|
||||||
|
provided is accurate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormFieldLabelProps {
|
||||||
|
field: FormField;
|
||||||
|
index: number;
|
||||||
|
showIndex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldLabel({
|
||||||
|
field,
|
||||||
|
index,
|
||||||
|
showIndex = true,
|
||||||
|
}: FormFieldLabelProps) {
|
||||||
|
const fieldType = FORM_FIELD_TYPES.find((f) => f.value === field.type);
|
||||||
|
return (
|
||||||
|
<Label>
|
||||||
|
{showIndex && `${index + 1}. `}
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type TourType =
|
|||||||
|
|
||||||
interface TourContextType {
|
interface TourContextType {
|
||||||
startTour: (tour: TourType) => void;
|
startTour: (tour: TourType) => void;
|
||||||
|
stopTour: () => void;
|
||||||
isTourActive: boolean;
|
isTourActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +355,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
onDestroyed: () => {
|
onDestroyed: () => {
|
||||||
// Persistence handled by localStorage state
|
localStorage.removeItem("hristudio_tour_mode");
|
||||||
|
Cookies.remove("hristudio_tour_mode");
|
||||||
setIsTourActive(false);
|
setIsTourActive(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -389,8 +391,18 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopTour = () => {
|
||||||
|
localStorage.removeItem("hristudio_tour_mode");
|
||||||
|
Cookies.remove("hristudio_tour_mode");
|
||||||
|
if (driverObj.current) {
|
||||||
|
driverObj.current.destroy();
|
||||||
|
driverObj.current = null;
|
||||||
|
}
|
||||||
|
setIsTourActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourContext.Provider value={{ startTour, isTourActive }}>
|
<TourContext.Provider value={{ startTour, stopTour, isTourActive }}>
|
||||||
{children}
|
{children}
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||||
|
import { trustLevelConfig } from "~/lib/constants";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -50,24 +51,6 @@ export type Plugin = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const trustLevelConfig = {
|
|
||||||
official: {
|
|
||||||
label: "Official",
|
|
||||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
|
||||||
description: "Official HRIStudio plugin",
|
|
||||||
},
|
|
||||||
verified: {
|
|
||||||
label: "Verified",
|
|
||||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
|
||||||
description: "Verified by the community",
|
|
||||||
},
|
|
||||||
community: {
|
|
||||||
label: "Community",
|
|
||||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
|
||||||
description: "Community contributed",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
active: {
|
active: {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
|
|||||||
+309
-240
@@ -11,6 +11,7 @@ import {
|
|||||||
EntityForm,
|
EntityForm,
|
||||||
FormField,
|
FormField,
|
||||||
FormSection,
|
FormSection,
|
||||||
|
NextSteps,
|
||||||
Tips,
|
Tips,
|
||||||
} from "~/components/ui/entity-form";
|
} from "~/components/ui/entity-form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
@@ -26,7 +27,7 @@ import { Textarea } from "~/components/ui/textarea";
|
|||||||
import { useStudyContext } from "~/lib/study-context";
|
import { useStudyContext } from "~/lib/study-context";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
import { Calendar as CalendarIcon, Clock, Clock2 } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -84,60 +85,61 @@ function DateTimePicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="grid gap-1.5">
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<Label htmlFor="date-picker" className="text-xs">
|
<PopoverTrigger asChild>
|
||||||
Date
|
<Button
|
||||||
</Label>
|
variant={"outline"}
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
id="date-picker"
|
||||||
<PopoverTrigger asChild>
|
className={cn(
|
||||||
<Button
|
"w-[200px] justify-start text-left font-normal",
|
||||||
variant={"outline"}
|
!value && "text-muted-foreground",
|
||||||
id="date-picker"
|
)}
|
||||||
className={cn(
|
>
|
||||||
"w-[240px] justify-start text-left font-normal",
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
!value && "text-muted-foreground",
|
{value ? format(value, "MMM d, yyyy") : <span>Pick a date</span>}
|
||||||
)}
|
</Button>
|
||||||
>
|
</PopoverTrigger>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
<Calendar
|
||||||
</Button>
|
mode="single"
|
||||||
</PopoverTrigger>
|
selected={value}
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
onSelect={onDateSelect}
|
||||||
<Calendar
|
initialFocus
|
||||||
mode="single"
|
/>
|
||||||
selected={value}
|
</PopoverContent>
|
||||||
onSelect={onDateSelect}
|
</Popover>
|
||||||
initialFocus
|
|
||||||
/>
|
<div className="relative">
|
||||||
</PopoverContent>
|
<Input
|
||||||
</Popover>
|
id="time-picker"
|
||||||
|
type="time"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={onTimeChange}
|
||||||
|
disabled={!value}
|
||||||
|
className="w-[110px]"
|
||||||
|
/>
|
||||||
|
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<Button
|
||||||
<Label htmlFor="time-picker" className="text-xs">
|
type="button"
|
||||||
Time
|
variant="outline"
|
||||||
</Label>
|
size="sm"
|
||||||
<div className="relative">
|
onClick={() => onChange(new Date())}
|
||||||
<Input
|
className="h-10 gap-1.5"
|
||||||
id="time-picker"
|
>
|
||||||
type="time"
|
<Clock2 className="h-4 w-4" />
|
||||||
value={timeValue}
|
Now
|
||||||
onChange={onTimeChange}
|
</Button>
|
||||||
disabled={!value}
|
|
||||||
className="w-[120px]"
|
|
||||||
/>
|
|
||||||
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trialSchema = z.object({
|
const trialSchema = z.object({
|
||||||
experimentId: z.string().uuid("Please select an experiment"),
|
experimentId: z.string().min(1, "Please select an experiment *"),
|
||||||
participantId: z.string().uuid("Please select a participant"),
|
participantId: z.string().min(1, "Please select a participant *"),
|
||||||
scheduledAt: z.date(),
|
scheduledAt: z.date({ message: "Scheduled date and time is required *" }),
|
||||||
wizardId: z.string().uuid().optional(),
|
wizardId: z.string().optional().or(z.literal("")),
|
||||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||||
sessionNumber: z
|
sessionNumber: z
|
||||||
.number()
|
.number()
|
||||||
@@ -165,7 +167,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
experimentId: "" as any,
|
experimentId: "" as any,
|
||||||
participantId: "" as any,
|
participantId: "" as any,
|
||||||
scheduledAt: new Date(),
|
scheduledAt: undefined,
|
||||||
wizardId: undefined,
|
wizardId: undefined,
|
||||||
notes: "",
|
notes: "",
|
||||||
sessionNumber: 1,
|
sessionNumber: 1,
|
||||||
@@ -269,8 +271,18 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
}
|
}
|
||||||
}, [trial, mode, form]);
|
}, [trial, mode, form]);
|
||||||
|
|
||||||
const createTrialMutation = api.trials.create.useMutation();
|
const createTrialMutation = api.trials.create.useMutation({
|
||||||
const updateTrialMutation = api.trials.update.useMutation();
|
onError: (error) => {
|
||||||
|
console.error("Create trial error:", error);
|
||||||
|
setError(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updateTrialMutation = api.trials.update.useMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Update trial error:", error);
|
||||||
|
setError(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Form submission
|
// Form submission
|
||||||
const onSubmit = async (data: TrialFormData) => {
|
const onSubmit = async (data: TrialFormData) => {
|
||||||
@@ -283,7 +295,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
experimentId: data.experimentId,
|
experimentId: data.experimentId,
|
||||||
participantId: data.participantId,
|
participantId: data.participantId,
|
||||||
scheduledAt: data.scheduledAt,
|
scheduledAt: data.scheduledAt,
|
||||||
wizardId: data.wizardId,
|
wizardId: data.wizardId || undefined,
|
||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -319,6 +331,249 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
return <div>Error loading trial: {fetchError.message}</div>;
|
return <div>Error loading trial: {fetchError.message}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar content
|
||||||
|
const sidebar = (
|
||||||
|
<>
|
||||||
|
<NextSteps
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
title: "Configure Experiment",
|
||||||
|
description: "Ensure the experiment protocol is designed and ready",
|
||||||
|
completed: !!form.watch("experimentId"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Select Participant",
|
||||||
|
description: "Choose a participant for this trial",
|
||||||
|
completed: !!form.watch("participantId"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Assign Wizard",
|
||||||
|
description: "Assign a wizard to operate the robot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Run Trial",
|
||||||
|
description: "Execute the trial and collect data",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tips
|
||||||
|
tips={[
|
||||||
|
"Verify experiment status: Only 'Ready' experiments can be used in trials.",
|
||||||
|
"Check participant availability: Ensure participants are available at the scheduled time.",
|
||||||
|
"Assign wizards early: Give wizards time to prepare before the trial.",
|
||||||
|
"Prepare notes: Add any special instructions for the wizard.",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const formFields = (
|
||||||
|
<>
|
||||||
|
<FormSection
|
||||||
|
title="Trial Configuration"
|
||||||
|
description="Select the experiment and participant for this trial."
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="experimentId">
|
||||||
|
Experiment <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("experimentId") ?? ""}
|
||||||
|
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||||
|
disabled={experimentsLoading || mode === "edit"}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={
|
||||||
|
form.formState.errors.experimentId ? "border-red-500 ring-1 ring-red-500" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
experimentsLoading
|
||||||
|
? "Loading experiments..."
|
||||||
|
: "Select an experiment"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{experimentsData?.map((experiment) => (
|
||||||
|
<SelectItem key={experiment.id} value={experiment.id}>
|
||||||
|
{experiment.name} ({experiment.status})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{form.formState.errors.experimentId && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||||
|
{form.formState.errors.experimentId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mode === "edit" && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Experiment cannot be changed after creation
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="participantId">
|
||||||
|
Participant <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("participantId") ?? ""}
|
||||||
|
onValueChange={(value) => form.setValue("participantId", value)}
|
||||||
|
disabled={participantsLoading || mode === "edit"}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={
|
||||||
|
form.formState.errors.participantId ? "border-red-500 ring-1 ring-red-500" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
participantsLoading
|
||||||
|
? "Loading participants..."
|
||||||
|
: "Select a participant"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{participantsData?.participants?.map((participant) => (
|
||||||
|
<SelectItem key={participant.id} value={participant.id}>
|
||||||
|
{participant.name ?? participant.participantCode}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{form.formState.errors.participantId && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||||
|
{form.formState.errors.participantId.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mode === "edit" && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Participant cannot be changed after creation
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
title="Scheduling"
|
||||||
|
description="Set when this trial should be conducted."
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="scheduledAt">
|
||||||
|
Scheduled Date & Time <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="scheduledAt"
|
||||||
|
render={({ field }) => (
|
||||||
|
<DateTimePicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.scheduledAt && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||||
|
{form.formState.errors.scheduledAt.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
When should this trial be conducted?
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||||
|
<Input
|
||||||
|
id="sessionNumber"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||||
|
placeholder="1"
|
||||||
|
className={
|
||||||
|
form.formState.errors.sessionNumber ? "border-red-500 ring-1 ring-red-500" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.sessionNumber && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||||
|
{form.formState.errors.sessionNumber.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Auto-incremented based on participant history
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection
|
||||||
|
title="Assignment & Notes"
|
||||||
|
description="Assign a wizard and add any special instructions."
|
||||||
|
>
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("wizardId") ?? "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||||
|
}
|
||||||
|
disabled={usersLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
usersLoading
|
||||||
|
? "Loading wizards..."
|
||||||
|
: "Select a wizard (optional)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||||
|
{usersData?.map(
|
||||||
|
(user: { id: string; name: string; email: string }) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.email})
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Who will operate the robot during this trial?
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
{...form.register("notes")}
|
||||||
|
placeholder="Special instructions for the wizard, environmental setup notes, or other relevant information..."
|
||||||
|
rows={4}
|
||||||
|
className={form.formState.errors.notes ? "border-red-500 ring-1 ring-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.notes && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||||
|
{form.formState.errors.notes.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Optional: Add any special instructions for this trial
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
</FormSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityForm
|
<EntityForm
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -341,196 +596,10 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
error={error}
|
error={error}
|
||||||
sidebar={undefined}
|
sidebar={sidebar}
|
||||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||||
layout="full-width"
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
{formFields}
|
||||||
{/* Left Column: Main Info (Spans 2) */}
|
|
||||||
<div className="space-y-6 md:col-span-2">
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="experimentId">Experiment *</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("experimentId") ?? ""}
|
|
||||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
|
||||||
disabled={experimentsLoading || mode === "edit"}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={
|
|
||||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
experimentsLoading
|
|
||||||
? "Loading experiments..."
|
|
||||||
: "Select an experiment"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{experimentsData?.map((experiment) => (
|
|
||||||
<SelectItem key={experiment.id} value={experiment.id}>
|
|
||||||
{experiment.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{form.formState.errors.experimentId && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
{form.formState.errors.experimentId.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{mode === "edit" && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Experiment cannot be changed after creation
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="participantId">Participant *</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("participantId") ?? ""}
|
|
||||||
onValueChange={(value) => form.setValue("participantId", value)}
|
|
||||||
disabled={participantsLoading || mode === "edit"}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={
|
|
||||||
form.formState.errors.participantId ? "border-red-500" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
participantsLoading
|
|
||||||
? "Loading participants..."
|
|
||||||
: "Select a participant"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{participantsData?.participants?.map((participant) => (
|
|
||||||
<SelectItem key={participant.id} value={participant.id}>
|
|
||||||
{participant.name ?? participant.participantCode} (
|
|
||||||
{participant.participantCode})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{form.formState.errors.participantId && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
{form.formState.errors.participantId.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{mode === "edit" && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Participant cannot be changed after creation
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="scheduledAt"
|
|
||||||
render={({ field }) => (
|
|
||||||
<DateTimePicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.scheduledAt && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
{form.formState.errors.scheduledAt.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
When should this trial be conducted?
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
|
||||||
<Input
|
|
||||||
id="sessionNumber"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
|
||||||
placeholder="1"
|
|
||||||
className={
|
|
||||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.sessionNumber && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
{form.formState.errors.sessionNumber.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Auto-incremented based on participant history
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Assignment & Notes (Spans 1) */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("wizardId") ?? "none"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
|
||||||
}
|
|
||||||
disabled={usersLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
usersLoading
|
|
||||||
? "Loading wizards..."
|
|
||||||
: "Select a wizard (optional)"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
|
||||||
{usersData?.map(
|
|
||||||
(user: { id: string; name: string; email: string }) => (
|
|
||||||
<SelectItem key={user.id} value={user.id}>
|
|
||||||
{user.name} ({user.email})
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Who will operate the robot?
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField>
|
|
||||||
<Label htmlFor="notes">Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
id="notes"
|
|
||||||
{...form.register("notes")}
|
|
||||||
placeholder="Special instructions..."
|
|
||||||
rows={5}
|
|
||||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
|
||||||
/>
|
|
||||||
{form.formState.errors.notes && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
{form.formState.errors.notes.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EntityForm>
|
</EntityForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,8 +430,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
order: step.order ?? index,
|
order: step.order ?? index,
|
||||||
actions:
|
actions:
|
||||||
step.actions
|
step.actions
|
||||||
?.filter((a) => a.type !== "branch")
|
?.map((action) => ({
|
||||||
.map((action) => ({
|
|
||||||
id: action.id,
|
id: action.id,
|
||||||
name: action.name,
|
name: action.name,
|
||||||
description: action.description,
|
description: action.description,
|
||||||
@@ -506,6 +505,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
||||||
setTrialStartTime(new Date());
|
setTrialStartTime(new Date());
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Start trial error:", error);
|
||||||
|
toast.error("Failed to start trial", { description: error.message });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeTrialMutation = api.trials.complete.useMutation({
|
const completeTrialMutation = api.trials.complete.useMutation({
|
||||||
@@ -528,6 +531,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTrial({ ...trial, status: data.status });
|
setTrial({ ...trial, status: data.status });
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Abort trial error:", error);
|
||||||
|
toast.error("Failed to abort trial", { description: error.message });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pauseTrialMutation = api.trials.pause.useMutation({
|
const pauseTrialMutation = api.trials.pause.useMutation({
|
||||||
@@ -785,8 +792,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: Linear progression
|
// Default: Linear progression (skip steps marked as skipped by branching)
|
||||||
const nextIndex = currentStepIndex + 1;
|
let nextIndex = currentStepIndex + 1;
|
||||||
|
while (nextIndex < steps.length && skippedSteps.has(nextIndex)) {
|
||||||
|
nextIndex++;
|
||||||
|
}
|
||||||
if (nextIndex < steps.length) {
|
if (nextIndex < steps.length) {
|
||||||
// Mark current step as complete
|
// Mark current step as complete
|
||||||
setCompletedSteps((prev) => {
|
setCompletedSteps((prev) => {
|
||||||
@@ -915,8 +925,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
// Log action execution
|
// Log action execution
|
||||||
console.log("Executing action:", actionId, parameters);
|
console.log("Executing action:", actionId, parameters);
|
||||||
|
|
||||||
// Handle branching logic (wizard_wait_for_response)
|
// Handle branching logic (wizard_wait_for_response / branch)
|
||||||
if (parameters?.value && parameters?.label) {
|
if (parameters?.label || parameters?.nextStepId) {
|
||||||
setLastResponse(String(parameters.value));
|
setLastResponse(String(parameters.value));
|
||||||
|
|
||||||
// If nextStepId is provided, jump immediately
|
// If nextStepId is provided, jump immediately
|
||||||
@@ -935,6 +945,24 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
console.log(
|
console.log(
|
||||||
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mark other branch targets as skipped so linear progression bypasses them
|
||||||
|
const branchingStep = steps[currentStepIndex];
|
||||||
|
const allOptions =
|
||||||
|
(branchingStep?.conditions?.options as any[]) ?? [];
|
||||||
|
setSkippedSteps((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const opt of allOptions) {
|
||||||
|
if (opt.nextStepId && opt.nextStepId !== nextId) {
|
||||||
|
const otherIdx = steps.findIndex(
|
||||||
|
(s) => s.id === opt.nextStepId,
|
||||||
|
);
|
||||||
|
if (otherIdx !== -1) next.add(otherIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
handleNextStep(targetIndex);
|
handleNextStep(targetIndex);
|
||||||
return; // Exit after jump
|
return; // Exit after jump
|
||||||
} else {
|
} else {
|
||||||
@@ -1306,8 +1334,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onStepSelect={handleStepSelect}
|
onStepSelect={handleStepSelect}
|
||||||
onExecuteAction={handleExecuteAction}
|
onExecuteAction={handleExecuteAction}
|
||||||
onExecuteRobotAction={handleExecuteRobotAction}
|
onExecuteRobotAction={handleExecuteRobotAction}
|
||||||
activeTab={executionPanelTab}
|
|
||||||
onTabChange={setExecutionPanelTab}
|
|
||||||
onSkipAction={handleSkipAction}
|
onSkipAction={handleSkipAction}
|
||||||
isExecuting={isExecutingAction}
|
isExecuting={isExecutingAction}
|
||||||
onNextStep={handleNextStep}
|
onNextStep={handleNextStep}
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ export function WizardActionItem({
|
|||||||
// Manual/Wizard Actions (Leaf nodes)
|
// Manual/Wizard Actions (Leaf nodes)
|
||||||
!isContainer &&
|
!isContainer &&
|
||||||
action.type !== "wizard_wait_for_response" &&
|
action.type !== "wizard_wait_for_response" &&
|
||||||
|
!isBranch &&
|
||||||
!isCompleted && (
|
!isCompleted && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -524,7 +525,7 @@ export function WizardActionItem({
|
|||||||
<div className="grid grid-cols-1 gap-2 pt-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 pt-3 sm:grid-cols-2">
|
||||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||||
const label = typeof opt === "string" ? opt : opt.label;
|
const label = typeof opt === "string" ? opt : opt.label;
|
||||||
const value = typeof opt === "string" ? opt : opt.value;
|
const value = typeof opt === "string" ? opt : (opt.value ?? opt.label);
|
||||||
const nextStepId =
|
const nextStepId =
|
||||||
typeof opt === "object" ? opt.nextStepId : undefined;
|
typeof opt === "object" ? opt.nextStepId : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -3,85 +3,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { WizardActionItem } from "./WizardActionItem";
|
import { WizardActionItem } from "./WizardActionItem";
|
||||||
import {
|
import {
|
||||||
Play,
|
|
||||||
SkipForward,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Clock,
|
Clock,
|
||||||
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 type { TrialData, StepData, TrialEvent } from "~/lib/types/trial";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
|
||||||
|
|
||||||
interface StepData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
|
||||||
parameters: Record<string, unknown>;
|
|
||||||
conditions?: {
|
|
||||||
options?: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
nextStepId?: string;
|
|
||||||
nextStepIndex?: number;
|
|
||||||
variant?:
|
|
||||||
| "default"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
| "secondary"
|
|
||||||
| "ghost"
|
|
||||||
| "link";
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
order: number;
|
|
||||||
actions?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
type: string;
|
|
||||||
parameters: Record<string, unknown>;
|
|
||||||
order: number;
|
|
||||||
pluginId: string | null;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrialData {
|
|
||||||
id: string;
|
|
||||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
|
||||||
scheduledAt: Date | null;
|
|
||||||
startedAt: Date | null;
|
|
||||||
completedAt: Date | null;
|
|
||||||
duration: number | null;
|
|
||||||
sessionNumber: number | null;
|
|
||||||
notes: string | null;
|
|
||||||
experimentId: string;
|
|
||||||
participantId: string | null;
|
|
||||||
wizardId: string | null;
|
|
||||||
experiment: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
studyId: string;
|
|
||||||
};
|
|
||||||
participant: {
|
|
||||||
id: string;
|
|
||||||
participantCode: string;
|
|
||||||
demographics: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrialEvent {
|
|
||||||
type: string;
|
|
||||||
timestamp: Date;
|
|
||||||
data?: unknown;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WizardExecutionPanelProps {
|
interface WizardExecutionPanelProps {
|
||||||
trial: TrialData;
|
trial: TrialData;
|
||||||
@@ -100,8 +29,6 @@ interface WizardExecutionPanelProps {
|
|||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
options?: { autoAdvance?: boolean },
|
options?: { autoAdvance?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
|
|
||||||
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
|
|
||||||
onSkipAction: (
|
onSkipAction: (
|
||||||
pluginName: string,
|
pluginName: string,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
@@ -118,7 +45,7 @@ interface WizardExecutionPanelProps {
|
|||||||
rosConnected?: boolean;
|
rosConnected?: boolean;
|
||||||
completedStepIndices?: Set<number>;
|
completedStepIndices?: Set<number>;
|
||||||
skippedStepIndices?: Set<number>;
|
skippedStepIndices?: Set<number>;
|
||||||
onLogEvent?: (type: string, data?: any) => void;
|
onLogEvent?: (type: string, data?: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardExecutionPanel({
|
export function WizardExecutionPanel({
|
||||||
@@ -130,8 +57,6 @@ export function WizardExecutionPanel({
|
|||||||
onStepSelect,
|
onStepSelect,
|
||||||
onExecuteAction,
|
onExecuteAction,
|
||||||
onExecuteRobotAction,
|
onExecuteRobotAction,
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
onSkipAction,
|
onSkipAction,
|
||||||
isExecuting = false,
|
isExecuting = false,
|
||||||
onNextStep,
|
onNextStep,
|
||||||
|
|||||||
@@ -153,7 +153,13 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e).catch((err) => console.error("handleSubmit error:", err));
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
{/* Form Fields */}
|
{/* Form Fields */}
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PageLayout } from "~/components/ui/page-layout";
|
||||||
|
|
||||||
|
interface TutorialStep {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TutorialPageProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
duration: string;
|
||||||
|
level: string;
|
||||||
|
steps: TutorialStep[];
|
||||||
|
prevTutorial?: {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
nextTutorial?: {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TutorialPage({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
duration,
|
||||||
|
level,
|
||||||
|
steps,
|
||||||
|
prevTutorial,
|
||||||
|
nextTutorial,
|
||||||
|
}: TutorialPageProps) {
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
Beginner: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||||
|
Intermediate: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
|
||||||
|
Advanced: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: "Help", href: "/help" },
|
||||||
|
{ label: "Tutorials", href: "/help/tutorials" },
|
||||||
|
{ label: title },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr_250px]">
|
||||||
|
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-4 space-y-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Tutorial Info</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${levelColors[level]}`}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Duration</span>
|
||||||
|
<span>{duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-medium">In This Tutorial</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{step.title}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{prevTutorial && (
|
||||||
|
<Button variant="outline" size="sm" className="justify-start" asChild>
|
||||||
|
<Link href={prevTutorial.href}>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
{prevTutorial.title}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{nextTutorial && (
|
||||||
|
<Button variant="outline" size="sm" className="justify-start" asChild>
|
||||||
|
<Link href={nextTutorial.href}>
|
||||||
|
{nextTutorial.title}
|
||||||
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border bg-card text-card-foreground shadow-sm ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useActiveStudy.ts
|
|
||||||
*
|
|
||||||
* Legacy placeholder for the deprecated `useActiveStudy` hook.
|
|
||||||
*
|
|
||||||
* This file exists solely to satisfy lingering TypeScript project
|
|
||||||
* service references (e.g. editor cached import paths) after the
|
|
||||||
* migration to the unified `useSelectedStudyDetails` hook.
|
|
||||||
*
|
|
||||||
* Previous responsibilities:
|
|
||||||
* - Exposed the currently "active" study id via localStorage.
|
|
||||||
* - Partially overlapped with a separate study context implementation.
|
|
||||||
*
|
|
||||||
* Migration:
|
|
||||||
* - All consumers should now import `useSelectedStudyDetails` from:
|
|
||||||
* `~/hooks/useSelectedStudyDetails`
|
|
||||||
* - That hook centralizes selection, metadata, counts, and role info.
|
|
||||||
*
|
|
||||||
* Safe Removal:
|
|
||||||
* - Once you are certain no editors / build artifacts reference this
|
|
||||||
* path, you may delete this file. It is intentionally tiny and has
|
|
||||||
* zero runtime footprint unless mistakenly invoked.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use `useSelectedStudyDetails()` instead.
|
|
||||||
* Legacy no-op placeholder retained only to satisfy stale references.
|
|
||||||
* Returns a neutral object so accidental invocations are harmless.
|
|
||||||
*/
|
|
||||||
export function useActiveStudy(): DeprecatedActiveStudyHookReturn {
|
|
||||||
return { studyId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type alias maintained for backward compatibility with (now removed)
|
|
||||||
* code that might have referenced the old hook's return type.
|
|
||||||
* Kept minimal on purpose.
|
|
||||||
*/
|
|
||||||
export interface DeprecatedActiveStudyHookReturn {
|
|
||||||
/** Previously the active study id (now: studyId in useSelectedStudyDetails) */
|
|
||||||
studyId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useActiveStudy;
|
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
export interface UseWizardRosOptions {
|
export interface UseWizardRosOptions {
|
||||||
autoConnect?: boolean;
|
autoConnect?: boolean;
|
||||||
|
simulationMode?: boolean;
|
||||||
onConnected?: () => void;
|
onConnected?: () => void;
|
||||||
onDisconnected?: () => void;
|
onDisconnected?: () => void;
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown) => void;
|
||||||
@@ -24,6 +25,7 @@ export interface UseWizardRosOptions {
|
|||||||
export interface UseWizardRosReturn {
|
export interface UseWizardRosReturn {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
|
isSimulationMode: boolean;
|
||||||
connectionError: string | null;
|
connectionError: string | null;
|
||||||
robotStatus: RobotStatus;
|
robotStatus: RobotStatus;
|
||||||
activeActions: RobotActionExecution[];
|
activeActions: RobotActionExecution[];
|
||||||
@@ -48,6 +50,7 @@ export interface UseWizardRosReturn {
|
|||||||
args?: Record<string, unknown>,
|
args?: Record<string, unknown>,
|
||||||
) => Promise<any>;
|
) => Promise<any>;
|
||||||
setAutonomousLife: (enabled: boolean) => Promise<boolean>;
|
setAutonomousLife: (enabled: boolean) => Promise<boolean>;
|
||||||
|
setSimulationMode: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWizardRos(
|
export function useWizardRos(
|
||||||
@@ -55,6 +58,7 @@ export function useWizardRos(
|
|||||||
): UseWizardRosReturn {
|
): UseWizardRosReturn {
|
||||||
const {
|
const {
|
||||||
autoConnect = true,
|
autoConnect = true,
|
||||||
|
simulationMode = false,
|
||||||
onConnected,
|
onConnected,
|
||||||
onDisconnected,
|
onDisconnected,
|
||||||
onError,
|
onError,
|
||||||
@@ -101,14 +105,17 @@ export function useWizardRos(
|
|||||||
// Initialize service (only once)
|
// Initialize service (only once)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitializedRef.current) {
|
if (!isInitializedRef.current) {
|
||||||
serviceRef.current = getWizardRosService();
|
serviceRef.current = getWizardRosService(simulationMode);
|
||||||
|
if (simulationMode) {
|
||||||
|
serviceRef.current.setSimulationMode(true);
|
||||||
|
}
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [simulationMode]);
|
||||||
|
|
||||||
// Set up event listeners with stable callbacks
|
// Set up event listeners with stable callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -381,9 +388,19 @@ export function useWizardRos(
|
|||||||
[isConnected],
|
[isConnected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setSimulationMode = useCallback((enabled: boolean) => {
|
||||||
|
const service = serviceRef.current;
|
||||||
|
if (service) {
|
||||||
|
service.setSimulationMode(enabled);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSimulationMode = serviceRef.current?.isSimulationMode() ?? simulationMode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
|
isSimulationMode,
|
||||||
connectionError,
|
connectionError,
|
||||||
robotStatus,
|
robotStatus,
|
||||||
activeActions,
|
activeActions,
|
||||||
@@ -392,5 +409,6 @@ export function useWizardRos(
|
|||||||
executeRobotAction,
|
executeRobotAction,
|
||||||
callService,
|
callService,
|
||||||
setAutonomousLife,
|
setAutonomousLife,
|
||||||
|
setSimulationMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { signOut } from "~/lib/auth-client";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth error codes that should trigger automatic logout
|
|
||||||
*/
|
|
||||||
const AUTH_ERROR_CODES = [
|
const AUTH_ERROR_CODES = [
|
||||||
"UNAUTHORIZED",
|
"UNAUTHORIZED",
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"UNAUTHENTICATED",
|
"UNAUTHENTICATED",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth error messages that should trigger automatic logout
|
|
||||||
*/
|
|
||||||
const AUTH_ERROR_MESSAGES = [
|
const AUTH_ERROR_MESSAGES = [
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
"unauthenticated",
|
"unauthenticated",
|
||||||
@@ -27,15 +21,10 @@ const AUTH_ERROR_MESSAGES = [
|
|||||||
"access denied",
|
"access denied",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if an error is an authentication/authorization error that should trigger logout
|
|
||||||
*/
|
|
||||||
export function isAuthError(error: unknown): boolean {
|
export function isAuthError(error: unknown): boolean {
|
||||||
if (!error) return false;
|
if (!error) return false;
|
||||||
|
|
||||||
// Check TRPC errors
|
|
||||||
if (error instanceof TRPCClientError) {
|
if (error instanceof TRPCClientError) {
|
||||||
// Check error code
|
|
||||||
const trpcErrorData = error.data as
|
const trpcErrorData = error.data as
|
||||||
| { code?: string; httpStatus?: number }
|
| { code?: string; httpStatus?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -47,24 +36,20 @@ export function isAuthError(error: unknown): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTTP status codes
|
|
||||||
const httpStatus = trpcErrorData?.httpStatus;
|
const httpStatus = trpcErrorData?.httpStatus;
|
||||||
if (httpStatus === 401 || httpStatus === 403) {
|
if (httpStatus === 401 || httpStatus === 403) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error message
|
|
||||||
const message = error.message?.toLowerCase() ?? "";
|
const message = error.message?.toLowerCase() ?? "";
|
||||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check generic errors
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const message = error.message?.toLowerCase() || "";
|
const message = error.message?.toLowerCase() || "";
|
||||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error objects with message property
|
|
||||||
if (typeof error === "object" && error !== null) {
|
if (typeof error === "object" && error !== null) {
|
||||||
if ("message" in error) {
|
if ("message" in error) {
|
||||||
const errorObj = error as { message: unknown };
|
const errorObj = error as { message: unknown };
|
||||||
@@ -72,7 +57,6 @@ export function isAuthError(error: unknown): boolean {
|
|||||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for status codes in error objects
|
|
||||||
if ("status" in error) {
|
if ("status" in error) {
|
||||||
const statusObj = error as { status: unknown };
|
const statusObj = error as { status: unknown };
|
||||||
const status = statusObj.status as number;
|
const status = statusObj.status as number;
|
||||||
@@ -83,9 +67,6 @@ export function isAuthError(error: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles authentication errors by logging out the user
|
|
||||||
*/
|
|
||||||
export async function handleAuthError(
|
export async function handleAuthError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
customMessage?: string,
|
customMessage?: string,
|
||||||
@@ -96,11 +77,9 @@ export async function handleAuthError(
|
|||||||
|
|
||||||
console.warn("Authentication error detected, logging out user:", error);
|
console.warn("Authentication error detected, logging out user:", error);
|
||||||
|
|
||||||
// Show user-friendly message
|
|
||||||
const message = customMessage ?? "Session expired. Please log in again.";
|
const message = customMessage ?? "Session expired. Please log in again.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
|
|
||||||
// Small delay to let the toast show
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -108,72 +87,19 @@ export async function handleAuthError(
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
} catch (signOutError) {
|
} catch (signOutError) {
|
||||||
console.error("Error during sign out:", signOutError);
|
console.error("Error during sign out:", signOutError);
|
||||||
// Force redirect if signOut fails
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* React Query error handler that automatically handles auth errors
|
|
||||||
*/
|
|
||||||
export function createAuthErrorHandler(customMessage?: string) {
|
|
||||||
return (error: unknown) => {
|
|
||||||
void handleAuthError(error, customMessage);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* tRPC error handler that automatically handles auth errors
|
|
||||||
*/
|
|
||||||
export function handleTRPCError(error: unknown, customMessage?: string): void {
|
|
||||||
void handleAuthError(error, customMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic error handler for any error type
|
|
||||||
*/
|
|
||||||
export function handleGenericError(
|
|
||||||
error: unknown,
|
|
||||||
customMessage?: string,
|
|
||||||
): void {
|
|
||||||
void handleAuthError(error, customMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook-style error handler for use in React components
|
|
||||||
*/
|
|
||||||
export function useAuthErrorHandler() {
|
export function useAuthErrorHandler() {
|
||||||
return {
|
return {
|
||||||
handleAuthError: (error: unknown, customMessage?: string) => {
|
handleAuthError,
|
||||||
void handleAuthError(error, customMessage);
|
|
||||||
},
|
|
||||||
isAuthError,
|
isAuthError,
|
||||||
createErrorHandler: createAuthErrorHandler,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Higher-order function to wrap API calls with automatic auth error handling
|
|
||||||
*/
|
|
||||||
export function withAuthErrorHandling<
|
|
||||||
T extends (...args: unknown[]) => Promise<unknown>,
|
|
||||||
>(fn: T, customMessage?: string): T {
|
|
||||||
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
|
||||||
try {
|
|
||||||
return (await fn(...args)) as ReturnType<T>;
|
|
||||||
} catch (error) {
|
|
||||||
await handleAuthError(error, customMessage);
|
|
||||||
throw error; // Re-throw so calling code can handle it too
|
|
||||||
}
|
|
||||||
}) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to check if current error should show a generic error message
|
|
||||||
* (i.e., it's not an auth error that will auto-logout)
|
|
||||||
*/
|
|
||||||
export function shouldShowGenericError(error: unknown): boolean {
|
export function shouldShowGenericError(error: unknown): boolean {
|
||||||
return !isAuthError(error);
|
return !isAuthError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export const trustLevelConfig = {
|
||||||
|
official: {
|
||||||
|
label: "Official",
|
||||||
|
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||||
|
description: "Official HRIStudio plugin",
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
label: "Verified",
|
||||||
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
|
description: "Verified by the community",
|
||||||
|
},
|
||||||
|
community: {
|
||||||
|
label: "Community",
|
||||||
|
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||||
|
description: "Community contributed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusConfig = {
|
||||||
|
active: {
|
||||||
|
label: "Active",
|
||||||
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
|
description: "Plugin is active and working",
|
||||||
|
},
|
||||||
|
deprecated: {
|
||||||
|
label: "Deprecated",
|
||||||
|
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||||
|
description: "Plugin is deprecated",
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
label: "Inactive",
|
||||||
|
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||||
|
description: "Plugin is not active",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formStatusColors = {
|
||||||
|
pending: "bg-yellow-100 text-yellow-700",
|
||||||
|
completed: "bg-green-100 text-green-700",
|
||||||
|
rejected: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ const actionSourceSchema = z
|
|||||||
const executionDescriptorSchema = z
|
const executionDescriptorSchema = z
|
||||||
.object({
|
.object({
|
||||||
transport: z.enum(["ros2", "rest", "internal"]),
|
transport: z.enum(["ros2", "rest", "internal"]),
|
||||||
timeoutMs: z.number().int().positive().optional(),
|
timeoutMs: z.number().int().min(0).optional(),
|
||||||
retryable: z.boolean().optional(),
|
retryable: z.boolean().optional(),
|
||||||
ros2: z
|
ros2: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ export interface RobotActionExecution {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single frame in a multi-step joint animation.
|
||||||
|
* @param joint_names - NAO joint names to move (e.g. "LShoulderPitch")
|
||||||
|
* @param joint_angles - Target angles in radians, one per joint_name
|
||||||
|
* @param speed - Movement speed as a fraction of max (0.0–1.0)
|
||||||
|
* @param delay_after - Milliseconds to wait after publishing this frame
|
||||||
|
* before sending the next one. Defaults to 800 ms.
|
||||||
|
*/
|
||||||
|
export interface AnimationMovement {
|
||||||
|
joint_names: string[];
|
||||||
|
joint_angles: number[];
|
||||||
|
speed?: number;
|
||||||
|
delay_after?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified ROS WebSocket service for wizard interface
|
* Unified ROS WebSocket service for wizard interface
|
||||||
* Manages connection to rosbridge and handles robot action execution
|
* Manages connection to rosbridge and handles robot action execution
|
||||||
@@ -60,6 +75,10 @@ export class WizardRosService extends EventEmitter {
|
|||||||
private maxReconnectAttempts = 5;
|
private maxReconnectAttempts = 5;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
|
|
||||||
|
// Simulation mode
|
||||||
|
private simulationMode: boolean;
|
||||||
|
private simulationInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// Robot state
|
// Robot state
|
||||||
private robotStatus: RobotStatus = {
|
private robotStatus: RobotStatus = {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -73,15 +92,40 @@ export class WizardRosService extends EventEmitter {
|
|||||||
// Active action tracking
|
// Active action tracking
|
||||||
private activeActions: Map<string, RobotActionExecution> = new Map();
|
private activeActions: Map<string, RobotActionExecution> = new Map();
|
||||||
|
|
||||||
constructor(url: string = "ws://localhost:9090") {
|
constructor(url: string = "ws://localhost:9090", simulationMode: boolean = false) {
|
||||||
super();
|
super();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
this.simulationMode = simulationMode ||
|
||||||
|
(typeof window !== "undefined" && process.env.NEXT_PUBLIC_SIMULATION_MODE === "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in simulation mode
|
||||||
|
*/
|
||||||
|
isSimulationMode(): boolean {
|
||||||
|
return this.simulationMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable simulation mode
|
||||||
|
*/
|
||||||
|
setSimulationMode(enabled: boolean): void {
|
||||||
|
this.simulationMode = enabled;
|
||||||
|
if (!enabled && this.simulationInterval) {
|
||||||
|
clearInterval(this.simulationInterval);
|
||||||
|
this.simulationInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to ROS bridge WebSocket
|
* Connect to ROS bridge WebSocket
|
||||||
*/
|
*/
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
|
// Simulation mode - fake connection
|
||||||
|
if (this.simulationMode) {
|
||||||
|
return this.connectSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (
|
if (
|
||||||
this.isConnected ||
|
this.isConnected ||
|
||||||
@@ -167,6 +211,11 @@ export class WizardRosService extends EventEmitter {
|
|||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
|
||||||
|
if (this.simulationMode) {
|
||||||
|
this.disconnectSimulation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close(1000, "Manual disconnect");
|
this.ws.close(1000, "Manual disconnect");
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
@@ -178,10 +227,187 @@ export class WizardRosService extends EventEmitter {
|
|||||||
this.emit("disconnected");
|
this.emit("disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulation mode connection - simulates robot responses
|
||||||
|
*/
|
||||||
|
private async connectSimulation(): Promise<void> {
|
||||||
|
console.log(`[WizardROS] SIMULATION MODE - Connecting to mock robot`);
|
||||||
|
this.isConnected = true;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionAttempts = 0;
|
||||||
|
|
||||||
|
// Initialize mock robot state
|
||||||
|
const mockStates = this.getMockJointStates();
|
||||||
|
this.robotStatus = {
|
||||||
|
connected: true,
|
||||||
|
battery: 85,
|
||||||
|
position: { x: 0, y: 0, theta: 0 },
|
||||||
|
joints: mockStates.names.reduce((acc, name, i) => {
|
||||||
|
acc[name] = mockStates.positions[i] ?? 0;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
sensors: {},
|
||||||
|
lastUpdate: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start publishing simulated sensor data
|
||||||
|
this.simulationInterval = setInterval(() => {
|
||||||
|
this.publishSimulationData();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
this.emit("connected");
|
||||||
|
console.log(`[WizardROS] SIMULATION MODE - Connected to mock robot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulation mode disconnection
|
||||||
|
*/
|
||||||
|
private disconnectSimulation(): void {
|
||||||
|
console.log(`[WizardROS] SIMULATION MODE - Disconnecting`);
|
||||||
|
if (this.simulationInterval) {
|
||||||
|
clearInterval(this.simulationInterval);
|
||||||
|
this.simulationInterval = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.robotStatus.connected = false;
|
||||||
|
this.emit("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish simulated sensor data
|
||||||
|
*/
|
||||||
|
private publishSimulationData(): void {
|
||||||
|
if (!this.simulationMode || !this.isConnected) return;
|
||||||
|
|
||||||
|
const mockData = this.getMockJointStates();
|
||||||
|
this.updateJointStates(mockData);
|
||||||
|
this.robotStatus.battery = 85 + Math.random() * 2 - 1; // Slight variation
|
||||||
|
this.robotStatus.sensors = {
|
||||||
|
"/bumper": { left: false, right: false },
|
||||||
|
"/hand_touch": { leftHand: false, rightHand: false },
|
||||||
|
"/head_touch": { front: false, middle: false, rear: false },
|
||||||
|
"/sonar/left": { range: 0.5 + Math.random() * 0.5 },
|
||||||
|
"/sonar/right": { range: 0.5 + Math.random() * 0.5 },
|
||||||
|
};
|
||||||
|
this.robotStatus.lastUpdate = new Date();
|
||||||
|
this.emit("robot_status_updated", this.robotStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mock joint states for simulation
|
||||||
|
*/
|
||||||
|
private getMockJointStates(): { names: string[]; positions: number[] } {
|
||||||
|
const names = [
|
||||||
|
"HeadYaw", "HeadPitch",
|
||||||
|
"LShoulderPitch", "LShoulderRoll", "LElbowYaw", "LElbowRoll", "LWristYaw", "LHand",
|
||||||
|
"RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RElbowRoll", "RWristYaw", "RHand",
|
||||||
|
"LHipYawPitch", "LHipRoll", "LHipPitch", "LKneePitch", "LAnklePitch", "LAnkleRoll",
|
||||||
|
"RHipYawPitch", "RHipRoll", "RHipPitch", "RKneePitch", "RAnklePitch", "RAnkleRoll",
|
||||||
|
];
|
||||||
|
const positions = names.map(() => (Math.random() - 0.5) * 0.1);
|
||||||
|
return { names, positions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute action in simulation mode
|
||||||
|
*/
|
||||||
|
private async executeSimulationAction(
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
actionConfig?: {
|
||||||
|
topic: string;
|
||||||
|
messageType: string;
|
||||||
|
payloadMapping: {
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
transformFn?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
): Promise<RobotActionExecution> {
|
||||||
|
const executionId = `${pluginName}_${actionId}_${Date.now()}`;
|
||||||
|
const execution: RobotActionExecution = {
|
||||||
|
id: executionId,
|
||||||
|
actionId,
|
||||||
|
pluginName,
|
||||||
|
parameters,
|
||||||
|
status: "pending",
|
||||||
|
startTime: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activeActions.set(executionId, execution);
|
||||||
|
this.emit("action_started", execution);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execution.status = "executing";
|
||||||
|
this.activeActions.set(executionId, execution);
|
||||||
|
|
||||||
|
console.log(`[WizardROS] SIMULATION MODE - Executing ${actionId}:`, parameters);
|
||||||
|
|
||||||
|
// If the action config carries a gesture_sequence payload, run the sim animation handler
|
||||||
|
if (actionConfig?.payloadMapping?.payload) {
|
||||||
|
const payload = actionConfig.payloadMapping.payload as { type?: string; movements?: AnimationMovement[] };
|
||||||
|
if (payload.type === "gesture_sequence" && payload.movements?.length) {
|
||||||
|
await this.executeSimulationAnimationSequence(payload.movements);
|
||||||
|
execution.status = "completed";
|
||||||
|
execution.endTime = new Date();
|
||||||
|
this.emit("action_completed", execution);
|
||||||
|
this.activeActions.set(executionId, execution);
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate action execution based on action type
|
||||||
|
let duration = 500;
|
||||||
|
|
||||||
|
if (actionId === "say_text" || actionId === "say_with_emotion" || actionConfig?.topic === "/speech") {
|
||||||
|
const text = String(parameters.text || parameters.data || "Hello");
|
||||||
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
||||||
|
duration = 1500 + Math.max(1000, wordCount * 300);
|
||||||
|
} else if (actionId.includes("walk") || actionId === "stop_walking" || actionConfig?.topic === "/cmd_vel") {
|
||||||
|
duration = 500;
|
||||||
|
const speed = Number(parameters.speed) || 0.1;
|
||||||
|
if (actionId === "walk_forward") {
|
||||||
|
this.robotStatus.position.x += speed * 0.5;
|
||||||
|
} else if (actionId === "walk_backward") {
|
||||||
|
this.robotStatus.position.x -= speed * 0.5;
|
||||||
|
} else if (actionId === "turn_left") {
|
||||||
|
this.robotStatus.position.theta -= 0.5;
|
||||||
|
} else if (actionId === "turn_right") {
|
||||||
|
this.robotStatus.position.theta += 0.5;
|
||||||
|
}
|
||||||
|
} else if (actionConfig?.topic === "/joint_angles") {
|
||||||
|
duration = 1000;
|
||||||
|
} else if (actionId === "wake_up" || actionId === "rest" || actionId === "set_posture") {
|
||||||
|
duration = 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate async execution
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
|
||||||
|
execution.status = "completed";
|
||||||
|
execution.endTime = new Date();
|
||||||
|
this.emit("action_completed", execution);
|
||||||
|
} catch (error) {
|
||||||
|
execution.status = "failed";
|
||||||
|
execution.error = error instanceof Error ? error.message : String(error);
|
||||||
|
execution.endTime = new Date();
|
||||||
|
this.emit("action_failed", execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeActions.set(executionId, execution);
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if connected to ROS bridge
|
* Check if connected to ROS bridge
|
||||||
*/
|
*/
|
||||||
getConnectionStatus(): boolean {
|
getConnectionStatus(): boolean {
|
||||||
|
if (this.simulationMode) {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
|
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +439,11 @@ export class WizardRosService extends EventEmitter {
|
|||||||
throw new Error("Not connected to ROS bridge");
|
throw new Error("Not connected to ROS bridge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulation mode - simulate action execution
|
||||||
|
if (this.simulationMode) {
|
||||||
|
return this.executeSimulationAction(pluginName, actionId, parameters, actionConfig);
|
||||||
|
}
|
||||||
|
|
||||||
const executionId = `${pluginName}_${actionId}_${Date.now()}`;
|
const executionId = `${pluginName}_${actionId}_${Date.now()}`;
|
||||||
const execution: RobotActionExecution = {
|
const execution: RobotActionExecution = {
|
||||||
id: executionId,
|
id: executionId,
|
||||||
@@ -232,7 +463,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
|
|
||||||
// Execute based on action configuration or built-in mappings
|
// Execute based on action configuration or built-in mappings
|
||||||
if (actionConfig) {
|
if (actionConfig) {
|
||||||
await this.executeWithConfig(actionConfig, parameters);
|
await this.executeWithConfig(actionConfig, parameters, actionId);
|
||||||
} else {
|
} else {
|
||||||
await this.executeBuiltinAction(actionId, parameters);
|
await this.executeBuiltinAction(actionId, parameters);
|
||||||
}
|
}
|
||||||
@@ -258,10 +489,65 @@ export class WizardRosService extends EventEmitter {
|
|||||||
return Array.from(this.activeActions.values());
|
return Array.from(this.activeActions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a multi-frame joint animation by sending each movement in sequence.
|
||||||
|
* Each frame is published to /joint_angles then held for delay_after ms
|
||||||
|
* (default 800 ms) before the next frame is sent.
|
||||||
|
*/
|
||||||
|
async executeAnimationSequence(movements: AnimationMovement[]): Promise<void> {
|
||||||
|
if (!movements.length) {
|
||||||
|
console.warn("[WizardROS] executeAnimationSequence called with empty movements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < movements.length; i++) {
|
||||||
|
const movement = movements[i]!;
|
||||||
|
console.log(
|
||||||
|
`[WizardROS] Animation frame ${i + 1}/${movements.length}:`,
|
||||||
|
movement.joint_names,
|
||||||
|
"→",
|
||||||
|
movement.joint_angles,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.publish(
|
||||||
|
"/joint_angles",
|
||||||
|
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||||
|
{
|
||||||
|
joint_names: movement.joint_names,
|
||||||
|
joint_angles: movement.joint_angles,
|
||||||
|
speed: movement.speed ?? 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always wait after each frame (including the last) so the caller
|
||||||
|
// can await this method and know the animation has finished.
|
||||||
|
const delayMs = movement.delay_after ?? 800;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a multi-frame joint animation in simulation mode (logs only).
|
||||||
|
*/
|
||||||
|
private async executeSimulationAnimationSequence(
|
||||||
|
movements: AnimationMovement[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < movements.length; i++) {
|
||||||
|
const movement = movements[i]!;
|
||||||
|
console.log(
|
||||||
|
`[WizardROS] SIMULATION animation frame ${i + 1}/${movements.length}:`,
|
||||||
|
movement.joint_names,
|
||||||
|
);
|
||||||
|
const delayMs = movement.delay_after ?? 800;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to robot sensor topics
|
* Subscribe to robot sensor topics
|
||||||
*/
|
*/
|
||||||
private subscribeToRobotTopics(): void {
|
private subscribeToRobotTopics(): void {
|
||||||
|
console.log("[WizardROS] Setting up robot topics...");
|
||||||
const topics = [
|
const topics = [
|
||||||
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
|
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
|
||||||
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
||||||
@@ -274,6 +560,12 @@ export class WizardRosService extends EventEmitter {
|
|||||||
topics.forEach(({ topic, type }) => {
|
topics.forEach(({ topic, type }) => {
|
||||||
this.subscribe(topic, type);
|
this.subscribe(topic, type);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.advertise("/speech", "std_msgs/String");
|
||||||
|
this.advertise("/cmd_vel", "geometry_msgs/Twist");
|
||||||
|
this.advertise("/joint_angles", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed");
|
||||||
|
this.advertise("/robot_pose", "geometry_msgs/Pose");
|
||||||
|
this.advertise("/animation", "std_msgs/String");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +582,21 @@ export class WizardRosService extends EventEmitter {
|
|||||||
this.send(message);
|
this.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advertise a ROS topic (declare the type before publishing)
|
||||||
|
*/
|
||||||
|
private advertise(topic: string, messageType: string): void {
|
||||||
|
console.log(`[WizardROS] Advertising topic ${topic} as ${messageType}`);
|
||||||
|
const message: RosMessage = {
|
||||||
|
op: "advertise",
|
||||||
|
topic,
|
||||||
|
type: messageType,
|
||||||
|
id: `adv_${this.messageId++}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish message to ROS topic
|
* Publish message to ROS topic
|
||||||
*/
|
*/
|
||||||
@@ -409,10 +716,30 @@ export class WizardRosService extends EventEmitter {
|
|||||||
type: string;
|
type: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
transformFn?: string;
|
transformFn?: string;
|
||||||
|
service?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
sshCommand?: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
|
actionId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// SSH command actions
|
||||||
|
if (config.payloadMapping.type === "ssh" && config.payloadMapping.sshCommand) {
|
||||||
|
await this.executeSSHCommand(config.payloadMapping.sshCommand);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service-call actions — no topic publish involved
|
||||||
|
if (config.payloadMapping.type === "service") {
|
||||||
|
const service = config.payloadMapping.service ?? config.topic;
|
||||||
|
const args = config.payloadMapping.args
|
||||||
|
? this.buildTemplatePayload(config.payloadMapping.args, parameters)
|
||||||
|
: parameters;
|
||||||
|
await this.callService(service, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let msg: Record<string, unknown>;
|
let msg: Record<string, unknown>;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -420,22 +747,31 @@ export class WizardRosService extends EventEmitter {
|
|||||||
config.payloadMapping.type === "static") &&
|
config.payloadMapping.type === "static") &&
|
||||||
config.payloadMapping.payload
|
config.payloadMapping.payload
|
||||||
) {
|
) {
|
||||||
// Template-based payload construction
|
msg = this.buildTemplatePayload(config.payloadMapping.payload, parameters);
|
||||||
msg = this.buildTemplatePayload(
|
|
||||||
config.payloadMapping.payload,
|
|
||||||
parameters,
|
|
||||||
);
|
|
||||||
} else if (config.payloadMapping.transformFn) {
|
} else if (config.payloadMapping.transformFn) {
|
||||||
// Custom transform function
|
msg = this.applyTransformFunction(config.payloadMapping.transformFn, parameters);
|
||||||
msg = this.applyTransformFunction(
|
|
||||||
config.payloadMapping.transformFn,
|
|
||||||
parameters,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Direct parameter mapping
|
|
||||||
msg = parameters;
|
msg = parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegate gesture_sequence payloads to the animation handler
|
||||||
|
if ((msg as { type?: string }).type === "gesture_sequence") {
|
||||||
|
const movements = (msg as { movements?: AnimationMovement[] }).movements;
|
||||||
|
if (!movements?.length) {
|
||||||
|
console.warn("[WizardROS] gesture_sequence payload has no movements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[WizardROS] Delegating to animation handler (${movements.length} frames)`);
|
||||||
|
await this.executeAnimationSequence(movements);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route /animation topic through SSH instead of ROS to avoid crashes
|
||||||
|
if (config.topic === "/animation" && actionId?.startsWith("play_animation_")) {
|
||||||
|
await this.executeAnimationSSH(actionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.publish(config.topic, config.messageType, msg);
|
this.publish(config.topic, config.messageType, msg);
|
||||||
|
|
||||||
// Wait for action completion based on topic type
|
// Wait for action completion based on topic type
|
||||||
@@ -524,7 +860,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
case "turn_head":
|
case "turn_head":
|
||||||
this.publish(
|
this.publish(
|
||||||
"/joint_angles",
|
"/joint_angles",
|
||||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||||
{
|
{
|
||||||
joint_names: ["HeadYaw", "HeadPitch"],
|
joint_names: ["HeadYaw", "HeadPitch"],
|
||||||
joint_angles: [
|
joint_angles: [
|
||||||
@@ -537,12 +873,12 @@ export class WizardRosService extends EventEmitter {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "move_arm":
|
case "move_arm": {
|
||||||
const arm = String(parameters.arm || "right");
|
const arm = String(parameters.arm || "right");
|
||||||
const prefix = arm.toLowerCase() === "left" ? "L" : "R";
|
const prefix = arm.toLowerCase() === "left" ? "L" : "R";
|
||||||
this.publish(
|
this.publish(
|
||||||
"/joint_angles",
|
"/joint_angles",
|
||||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||||
{
|
{
|
||||||
joint_names: [
|
joint_names: [
|
||||||
`${prefix}ShoulderPitch`,
|
`${prefix}ShoulderPitch`,
|
||||||
@@ -561,6 +897,40 @@ export class WizardRosService extends EventEmitter {
|
|||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "wake_up":
|
||||||
|
// Try known naoqi_driver2 service names in order
|
||||||
|
for (const svc of [
|
||||||
|
"/naoqi_driver/ALMotion/wakeUp",
|
||||||
|
"/naoqi_driver/motion/wake_up",
|
||||||
|
"/motion/wake_up",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await this.callService(svc, {});
|
||||||
|
console.log(`[WizardROS] wake_up succeeded via ${svc}`);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rest":
|
||||||
|
for (const svc of [
|
||||||
|
"/naoqi_driver/ALMotion/rest",
|
||||||
|
"/naoqi_driver/motion/rest",
|
||||||
|
"/motion/rest",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await this.callService(svc, {});
|
||||||
|
console.log(`[WizardROS] rest succeeded via ${svc}`);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "emergency_stop":
|
case "emergency_stop":
|
||||||
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
@@ -569,6 +939,16 @@ export class WizardRosService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "play_animation_bow":
|
||||||
|
case "play_animation_hey":
|
||||||
|
case "play_animation_show_floor":
|
||||||
|
case "play_animation_enthusiastic":
|
||||||
|
case "play_animation_yes":
|
||||||
|
case "play_animation_no":
|
||||||
|
case "play_animation_idontknow":
|
||||||
|
await this.executeAnimationSSH(actionId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown action: ${actionId}. Define this action in your robot plugin.`,
|
`Unknown action: ${actionId}. Define this action in your robot plugin.`,
|
||||||
@@ -587,6 +967,42 @@ export class WizardRosService extends EventEmitter {
|
|||||||
throw new Error("Not connected to ROS bridge");
|
throw new Error("Not connected to ROS bridge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulation mode - return mock responses
|
||||||
|
if (this.simulationMode) {
|
||||||
|
console.log(`[WizardROS] SIMULATION MODE - Service call: ${service}`, args);
|
||||||
|
|
||||||
|
const mockResponses: Record<string, ServiceResponse> = {
|
||||||
|
"/naoqi_driver/get_robot_info": {
|
||||||
|
result: true,
|
||||||
|
values: {
|
||||||
|
robotName: "MOCK-NAO6",
|
||||||
|
robotVersion: "6.0",
|
||||||
|
bodyType: "nao",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/naoqi_driver/get_joint_names": {
|
||||||
|
result: true,
|
||||||
|
values: {
|
||||||
|
joint_names: [
|
||||||
|
"HeadYaw", "HeadPitch", "LShoulderPitch", "LShoulderRoll",
|
||||||
|
"LElbowYaw", "LElbowRoll", "LWristYaw", "LHand",
|
||||||
|
"RShoulderPitch", "RShoulderRoll", "RElbowYaw", "RElbowRoll",
|
||||||
|
"RWristYaw", "RHand", "LHipYawPitch", "LHipRoll",
|
||||||
|
"LHipPitch", "LKneePitch", "LAnklePitch", "LAnkleRoll",
|
||||||
|
"RHipYawPitch", "RHipRoll", "RHipPitch", "RKneePitch",
|
||||||
|
"RAnklePitch", "RAnkleRoll",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/naoqi_driver/get_position": {
|
||||||
|
result: true,
|
||||||
|
values: this.robotStatus.position,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return mockResponses[service] || { result: true };
|
||||||
|
}
|
||||||
|
|
||||||
const id = `call_${this.messageId++}`;
|
const id = `call_${this.messageId++}`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -620,6 +1036,56 @@ export class WizardRosService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute animation via API route (SSH to robot)
|
||||||
|
*/
|
||||||
|
private async executeAnimationSSH(actionId: string): Promise<void> {
|
||||||
|
const animationMap: Record<string, string> = {
|
||||||
|
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
|
||||||
|
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
|
||||||
|
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
|
||||||
|
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
|
||||||
|
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
|
||||||
|
"play_animation_no": "animations/Stand/Gestures/No_3",
|
||||||
|
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const animation = animationMap[actionId];
|
||||||
|
if (!animation) {
|
||||||
|
throw new Error(`Unknown animation: ${actionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WizardROS] Executing animation via API: ${animation}`);
|
||||||
|
|
||||||
|
// Use executeSSH to run animation via qicli (bypasses studyId requirement)
|
||||||
|
await this.executeSSHCommand(`qicli call ALAnimationPlayer.run '${animation}'`);
|
||||||
|
|
||||||
|
console.log(`[WizardROS] Animation completed: ${animation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an arbitrary SSH command via the API
|
||||||
|
*/
|
||||||
|
private async executeSSHCommand(command: string): Promise<void> {
|
||||||
|
console.log(`[WizardROS] Executing SSH command: ${command}`);
|
||||||
|
|
||||||
|
const response = await fetch("/api/robots/command", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "executeSSH",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`SSH command failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WizardROS] SSH command completed: ${command}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set Autonomous Life state with fallbacks
|
* Set Autonomous Life state with fallbacks
|
||||||
*/
|
*/
|
||||||
@@ -783,6 +1249,21 @@ export class WizardRosService extends EventEmitter {
|
|||||||
case "transformToEmotionalSpeech":
|
case "transformToEmotionalSpeech":
|
||||||
return this.transformToEmotionalSpeech(parameters);
|
return this.transformToEmotionalSpeech(parameters);
|
||||||
|
|
||||||
|
case "transformToEmotionSpeech":
|
||||||
|
return this.transformToEmotionalSpeech(parameters);
|
||||||
|
|
||||||
|
case "transformToWaveGoodbye":
|
||||||
|
return this.transformToWaveGoodbye(parameters);
|
||||||
|
|
||||||
|
case "transformToAnimation":
|
||||||
|
return this.transformToAnimation(parameters);
|
||||||
|
|
||||||
|
case "transformToGestureSequence":
|
||||||
|
return this.transformToGestureSequence(parameters);
|
||||||
|
|
||||||
|
case "transformToWaveGoodbye":
|
||||||
|
return this.transformToWaveGoodbye(parameters);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown transform function: ${transformFn}`);
|
console.warn(`Unknown transform function: ${transformFn}`);
|
||||||
return parameters;
|
return parameters;
|
||||||
@@ -792,34 +1273,26 @@ export class WizardRosService extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Transform parameters for emotional speech
|
* Transform parameters for emotional speech
|
||||||
* NAOqi markup: \rspd=<speed>\<text>
|
* NAOqi markup: \rspd=<speed>\<text>
|
||||||
* For animated speech: ^start(animations/Stand/Gestures/...)
|
* Using pure speech modifiers without animations to avoid sound effects
|
||||||
*/
|
*/
|
||||||
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
|
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
|
||||||
data: string;
|
data: string;
|
||||||
} {
|
} {
|
||||||
const text = String(parameters.text || "Hello");
|
const text = String(parameters.text || "Hello");
|
||||||
const emotion = String(parameters.emotion || "neutral");
|
const emotion = String(parameters.emotion || "neutral");
|
||||||
const speed = Number(parameters.speed || 1.0);
|
|
||||||
const speedPercent = Math.round(speed * 100);
|
|
||||||
|
|
||||||
let markedText = text;
|
let markedText = text;
|
||||||
|
|
||||||
switch (emotion) {
|
switch (emotion) {
|
||||||
case "happy":
|
case "happy":
|
||||||
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`;
|
markedText = `\\rspd=120\\ ${text}`;
|
||||||
break;
|
|
||||||
case "excited":
|
|
||||||
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
|
|
||||||
break;
|
break;
|
||||||
case "sad":
|
case "sad":
|
||||||
markedText = `\\rspd=80\\vct=80\\${text}`;
|
markedText = `\\rspd=80\\ ${text}`;
|
||||||
break;
|
|
||||||
case "calm":
|
|
||||||
markedText = `\\rspd=90\\${text}`;
|
|
||||||
break;
|
break;
|
||||||
case "neutral":
|
case "neutral":
|
||||||
default:
|
default:
|
||||||
markedText = `\\rspd=${speedPercent}\\${text}`;
|
markedText = text;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,14 +1300,13 @@ export class WizardRosService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform for wave goodbye - animated speech with waving
|
* Transform for wave goodbye - speech without animation sound
|
||||||
*/
|
*/
|
||||||
private transformToWaveGoodbye(parameters: Record<string, unknown>): {
|
private transformToWaveGoodbye(parameters: Record<string, unknown>): {
|
||||||
data: string;
|
data: string;
|
||||||
} {
|
} {
|
||||||
const text = String(parameters.text || "Goodbye!");
|
const text = String(parameters.text || "Goodbye!");
|
||||||
const markedText = `\\rspd=110\\^start(animations/Stand/Gestures/Hey_1) ${text} ^start(animations/Stand/Gestures/Hey_1)`;
|
return { data: text };
|
||||||
return { data: markedText };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -848,6 +1320,38 @@ export class WizardRosService extends EventEmitter {
|
|||||||
return { data: markedText };
|
return { data: markedText };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform for gesture sequences - sends multiple joint angle movements
|
||||||
|
* Parameters: movements = [{joints: string[], angles: number[], duration: number}, ...]
|
||||||
|
*/
|
||||||
|
private transformToGestureSequence(parameters: Record<string, unknown>): {
|
||||||
|
type: string;
|
||||||
|
movements: Array<{
|
||||||
|
joint_names: string[];
|
||||||
|
joint_angles: number[];
|
||||||
|
speed: number;
|
||||||
|
}>;
|
||||||
|
} {
|
||||||
|
const movements = parameters.movements as Array<{
|
||||||
|
joints?: string[];
|
||||||
|
angles?: number[];
|
||||||
|
duration?: number;
|
||||||
|
speed?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!Array.isArray(movements)) {
|
||||||
|
return { type: "gesture_sequence", movements: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMovements = movements.map((m, index) => ({
|
||||||
|
joint_names: m.joints || [],
|
||||||
|
joint_angles: m.angles || [],
|
||||||
|
speed: m.speed || (m.duration ? 1 / (m.duration / 1000) : 0.3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { type: "gesture_sequence", movements: parsedMovements };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule reconnection attempt
|
* Schedule reconnection attempt
|
||||||
*/
|
*/
|
||||||
@@ -892,7 +1396,7 @@ let isCreatingInstance = false;
|
|||||||
/**
|
/**
|
||||||
* Get or create the global wizard ROS service (true singleton)
|
* Get or create the global wizard ROS service (true singleton)
|
||||||
*/
|
*/
|
||||||
export function getWizardRosService(): WizardRosService {
|
export function getWizardRosService(simulationMode?: boolean): WizardRosService {
|
||||||
// Prevent multiple instances during creation
|
// Prevent multiple instances during creation
|
||||||
if (isCreatingInstance && !wizardRosService) {
|
if (isCreatingInstance && !wizardRosService) {
|
||||||
throw new Error("WizardRosService is being initialized, please wait");
|
throw new Error("WizardRosService is being initialized, please wait");
|
||||||
@@ -901,7 +1405,10 @@ export function getWizardRosService(): WizardRosService {
|
|||||||
if (!wizardRosService) {
|
if (!wizardRosService) {
|
||||||
isCreatingInstance = true;
|
isCreatingInstance = true;
|
||||||
try {
|
try {
|
||||||
wizardRosService = new WizardRosService();
|
const url = typeof window !== "undefined"
|
||||||
|
? (process.env.NEXT_PUBLIC_ROS_BRIDGE_URL || "ws://localhost:9090")
|
||||||
|
: "ws://localhost:9090";
|
||||||
|
wizardRosService = new WizardRosService(url, simulationMode);
|
||||||
} finally {
|
} finally {
|
||||||
isCreatingInstance = false;
|
isCreatingInstance = false;
|
||||||
}
|
}
|
||||||
@@ -912,8 +1419,12 @@ export function getWizardRosService(): WizardRosService {
|
|||||||
/**
|
/**
|
||||||
* Initialize wizard ROS service with connection
|
* Initialize wizard ROS service with connection
|
||||||
*/
|
*/
|
||||||
export async function initWizardRosService(): Promise<WizardRosService> {
|
export async function initWizardRosService(simulationMode?: boolean): Promise<WizardRosService> {
|
||||||
const service = getWizardRosService();
|
const service = getWizardRosService(simulationMode);
|
||||||
|
|
||||||
|
if (simulationMode !== undefined) {
|
||||||
|
service.setSimulationMode(simulationMode);
|
||||||
|
}
|
||||||
|
|
||||||
if (!service.getConnectionStatus()) {
|
if (!service.getConnectionStatus()) {
|
||||||
await service.connect();
|
await service.connect();
|
||||||
@@ -921,3 +1432,13 @@ export async function initWizardRosService(): Promise<WizardRosService> {
|
|||||||
|
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the global wizard ROS service (useful for testing or reinitializing)
|
||||||
|
*/
|
||||||
|
export function resetWizardRosService(): void {
|
||||||
|
if (wizardRosService) {
|
||||||
|
wizardRosService.disconnect();
|
||||||
|
wizardRosService = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface FormFieldSettings {
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormField {
|
||||||
|
id: string;
|
||||||
|
type: FormFieldType;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
options?: string[];
|
||||||
|
settings?: FormFieldSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormFieldType =
|
||||||
|
| "text"
|
||||||
|
| "textarea"
|
||||||
|
| "multiple_choice"
|
||||||
|
| "checkbox"
|
||||||
|
| "rating"
|
||||||
|
| "yes_no"
|
||||||
|
| "date"
|
||||||
|
| "signature";
|
||||||
|
|
||||||
|
export type FormType = "consent" | "survey" | "questionnaire";
|
||||||
|
|
||||||
|
export interface FormFieldTypeConfig {
|
||||||
|
value: FormFieldType;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FORM_FIELD_TYPES: FormFieldTypeConfig[] = [
|
||||||
|
{ value: "text", label: "Text (short)", icon: "📝" },
|
||||||
|
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
||||||
|
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
||||||
|
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
||||||
|
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
||||||
|
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
||||||
|
{ value: "date", label: "Date", icon: "📅" },
|
||||||
|
{ value: "signature", label: "Signature", icon: "✍️" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createField(type: FormFieldType): FormField {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type,
|
||||||
|
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||||
|
required: false,
|
||||||
|
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export interface StepData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
conditions?: {
|
||||||
|
options?: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
nextStepId?: string;
|
||||||
|
nextStepIndex?: number;
|
||||||
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
order: number;
|
||||||
|
actions?: ActionData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
order: number;
|
||||||
|
pluginId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrialData {
|
||||||
|
id: string;
|
||||||
|
status: TrialStatus;
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
duration: number | null;
|
||||||
|
sessionNumber: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
experimentId: string;
|
||||||
|
participantId: string | null;
|
||||||
|
wizardId: string | null;
|
||||||
|
experiment: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
studyId: string;
|
||||||
|
};
|
||||||
|
participant: {
|
||||||
|
id: string;
|
||||||
|
participantCode: string;
|
||||||
|
demographics: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrialStatus =
|
||||||
|
| "scheduled"
|
||||||
|
| "in_progress"
|
||||||
|
| "completed"
|
||||||
|
| "aborted"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface TrialEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp: Date;
|
||||||
|
data?: unknown;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
@@ -675,8 +675,11 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
// Delete existing steps and actions for this experiment
|
// Delete existing steps and actions for this experiment
|
||||||
await ctx.db.delete(steps).where(eq(steps.experimentId, id));
|
await ctx.db.delete(steps).where(eq(steps.experimentId, id));
|
||||||
|
|
||||||
|
// Map from designer temp step ID → new DB UUID (for branch nextStepId fix-up)
|
||||||
|
const stepIdMap = new Map<string, string>();
|
||||||
|
|
||||||
// Create new steps and actions
|
// Create new steps and actions
|
||||||
for (const convertedStep of convertedSteps) {
|
for (const [i, convertedStep] of convertedSteps.entries()) {
|
||||||
const [newStep] = await ctx.db
|
const [newStep] = await ctx.db
|
||||||
.insert(steps)
|
.insert(steps)
|
||||||
.values({
|
.values({
|
||||||
@@ -698,6 +701,10 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record temp ID → real UUID so branch nextStepId refs can be fixed up
|
||||||
|
const tempId = normalizedSteps[i]?.id;
|
||||||
|
if (tempId) stepIdMap.set(tempId, newStep.id);
|
||||||
|
|
||||||
// Create actions for this step
|
// Create actions for this step
|
||||||
for (const convertedAction of convertedStep.actions) {
|
for (const convertedAction of convertedStep.actions) {
|
||||||
await ctx.db.insert(actions).values({
|
await ctx.db.insert(actions).values({
|
||||||
@@ -724,6 +731,25 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix-up branch nextStepId: replace temp designer IDs with real DB UUIDs
|
||||||
|
// in both action parameters and step conditions
|
||||||
|
for (const [tempId, dbId] of stepIdMap) {
|
||||||
|
await ctx.db.execute(
|
||||||
|
sql`UPDATE ${actions}
|
||||||
|
SET parameters = replace(parameters::text, ${tempId}, ${dbId})::jsonb
|
||||||
|
WHERE step_id IN (
|
||||||
|
SELECT id FROM ${steps} WHERE experiment_id = ${id}
|
||||||
|
)
|
||||||
|
AND parameters::text LIKE ${"%" + tempId + "%"}`,
|
||||||
|
);
|
||||||
|
await ctx.db.execute(
|
||||||
|
sql`UPDATE ${steps}
|
||||||
|
SET conditions = replace(conditions::text, ${tempId}, ${dbId})::jsonb
|
||||||
|
WHERE experiment_id = ${id}
|
||||||
|
AND conditions::text LIKE ${"%" + tempId + "%"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
|||||||
+235
-48
@@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
|
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
activityLogs,
|
activityLogs,
|
||||||
formResponses,
|
formResponses,
|
||||||
@@ -11,6 +15,7 @@ import {
|
|||||||
formFieldTypeEnum,
|
formFieldTypeEnum,
|
||||||
participants,
|
participants,
|
||||||
studyMembers,
|
studyMembers,
|
||||||
|
studies,
|
||||||
userSystemRoles,
|
userSystemRoles,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ async function checkStudyAccess(
|
|||||||
|
|
||||||
export const formsRouter = createTRPCRouter({
|
export const formsRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
studyId: z.string().uuid(),
|
studyId: z.string().uuid(),
|
||||||
type: z.enum(formTypes).optional(),
|
type: z.enum(formTypes).optional(),
|
||||||
@@ -116,8 +121,11 @@ export const formsRouter = createTRPCRouter({
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(formResponses)
|
.from(formResponses)
|
||||||
.where(eq(formResponses.formId, form.id));
|
.where(eq(formResponses.formId, form.id));
|
||||||
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
return {
|
||||||
})
|
...form,
|
||||||
|
_count: { responses: responseCount[0]?.count ?? 0 },
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -178,16 +186,18 @@ export const formsRouter = createTRPCRouter({
|
|||||||
type: z.enum(formTypes),
|
type: z.enum(formTypes),
|
||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
fields: z.array(
|
fields: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
type: z.string(),
|
id: z.string(),
|
||||||
label: z.string(),
|
type: z.string(),
|
||||||
required: z.boolean().default(false),
|
label: z.string(),
|
||||||
options: z.array(z.string()).optional(),
|
required: z.boolean().default(false),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
options: z.array(z.string()).optional(),
|
||||||
}),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
).default([]),
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
isTemplate: z.boolean().optional(),
|
isTemplate: z.boolean().optional(),
|
||||||
templateName: z.string().max(100).optional(),
|
templateName: z.string().max(100).optional(),
|
||||||
@@ -195,7 +205,7 @@ export const formsRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { isTemplate, templateName, ...formData } = input;
|
const { isTemplate, templateName, ...formData } = input;
|
||||||
|
|
||||||
if (isTemplate && !templateName) {
|
if (isTemplate && !templateName) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -248,16 +258,18 @@ export const formsRouter = createTRPCRouter({
|
|||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
title: z.string().min(1).max(255).optional(),
|
title: z.string().min(1).max(255).optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
fields: z.array(
|
fields: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
type: z.string(),
|
id: z.string(),
|
||||||
label: z.string(),
|
type: z.string(),
|
||||||
required: z.boolean().default(false),
|
label: z.string(),
|
||||||
options: z.array(z.string()).optional(),
|
required: z.boolean().default(false),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
options: z.array(z.string()).optional(),
|
||||||
}),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
).optional(),
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -275,10 +287,12 @@ export const formsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
|
await checkStudyAccess(
|
||||||
"owner",
|
ctx.db,
|
||||||
"researcher",
|
ctx.session.user.id,
|
||||||
]);
|
existingForm.studyId,
|
||||||
|
["owner", "researcher"],
|
||||||
|
);
|
||||||
|
|
||||||
const [updatedForm] = await ctx.db
|
const [updatedForm] = await ctx.db
|
||||||
.update(forms)
|
.update(forms)
|
||||||
@@ -407,10 +421,12 @@ export const formsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
|
await checkStudyAccess(
|
||||||
"owner",
|
ctx.db,
|
||||||
"researcher",
|
ctx.session.user.id,
|
||||||
]);
|
existingForm.studyId,
|
||||||
|
["owner", "researcher"],
|
||||||
|
);
|
||||||
|
|
||||||
const latestForm = await ctx.db.query.forms.findFirst({
|
const latestForm = await ctx.db.query.forms.findFirst({
|
||||||
where: eq(forms.studyId, existingForm.studyId),
|
where: eq(forms.studyId, existingForm.studyId),
|
||||||
@@ -517,6 +533,81 @@ export const formsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
exportCsv: protectedProcedure
|
||||||
|
.input(z.object({ formId: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: eq(forms.id, input.formId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
|
||||||
|
|
||||||
|
const responses = await ctx.db.query.formResponses.findMany({
|
||||||
|
where: eq(formResponses.formId, input.formId),
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
participantCode: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(formResponses.submittedAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = form.fields as Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
const headers = [
|
||||||
|
"Participant Code",
|
||||||
|
"Name",
|
||||||
|
"Email",
|
||||||
|
"Status",
|
||||||
|
"Submitted At",
|
||||||
|
...fields.map((f) => f.label),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = responses.map((r) => {
|
||||||
|
const participantResponses = r.responses as Record<string, any>;
|
||||||
|
return [
|
||||||
|
r.participant?.participantCode ?? "",
|
||||||
|
r.participant?.name ?? "",
|
||||||
|
r.participant?.email ?? "",
|
||||||
|
r.status,
|
||||||
|
r.submittedAt?.toISOString() ?? "",
|
||||||
|
...fields.map((f) => {
|
||||||
|
const val = participantResponses[f.id];
|
||||||
|
if (val === undefined || val === null) return "";
|
||||||
|
if (typeof val === "boolean") return val ? "Yes" : "No";
|
||||||
|
return String(val);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const escape = (s: string | null | undefined) =>
|
||||||
|
`"${String(s ?? "").replace(/"/g, '""')}"`;
|
||||||
|
const csv = [
|
||||||
|
headers.map((h) => escape(h)).join(","),
|
||||||
|
...rows.map((row) => row.map((cell) => escape(cell)).join(",")),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
csv,
|
||||||
|
filename: `${form.title.replace(/\s+/g, "_")}_responses.csv`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
submitResponse: protectedProcedure
|
submitResponse: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -596,22 +687,24 @@ export const formsRouter = createTRPCRouter({
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(formResponses)
|
.from(formResponses)
|
||||||
.where(eq(formResponses.formId, form.id));
|
.where(eq(formResponses.formId, form.id));
|
||||||
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
return {
|
||||||
})
|
...form,
|
||||||
|
_count: { responses: responseCount[0]?.count ?? 0 },
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return formsWithCounts;
|
return formsWithCounts;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listTemplates: protectedProcedure
|
listTemplates: protectedProcedure.query(async ({ ctx }) => {
|
||||||
.query(async ({ ctx }) => {
|
const templates = await ctx.db.query.forms.findMany({
|
||||||
const templates = await ctx.db.query.forms.findMany({
|
where: eq(forms.isTemplate, true),
|
||||||
where: eq(forms.isTemplate, true),
|
orderBy: [desc(forms.updatedAt)],
|
||||||
orderBy: [desc(forms.updatedAt)],
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return templates;
|
return templates;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createFromTemplate: protectedProcedure
|
createFromTemplate: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -628,10 +721,7 @@ export const formsRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const template = await ctx.db.query.forms.findFirst({
|
const template = await ctx.db.query.forms.findFirst({
|
||||||
where: and(
|
where: and(eq(forms.id, input.templateId), eq(forms.isTemplate, true)),
|
||||||
eq(forms.id, input.templateId),
|
|
||||||
eq(forms.isTemplate, true),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -673,4 +763,101 @@ export const formsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return newForm;
|
return newForm;
|
||||||
}),
|
}),
|
||||||
});
|
|
||||||
|
getPublic: publicProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: and(eq(forms.id, input.id), eq(forms.active, true)),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
studyId: true,
|
||||||
|
type: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
version: true,
|
||||||
|
fields: true,
|
||||||
|
settings: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found or not active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const study = await ctx.db.query.studies.findFirst({
|
||||||
|
where: eq(studies.id, form.studyId),
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...form, studyName: study?.name };
|
||||||
|
}),
|
||||||
|
|
||||||
|
submitPublic: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().uuid(),
|
||||||
|
participantCode: z.string().min(1).max(100),
|
||||||
|
responses: z.record(z.string(), z.any()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { formId, participantCode, responses } = input;
|
||||||
|
|
||||||
|
const form = await ctx.db.query.forms.findFirst({
|
||||||
|
where: and(eq(forms.id, formId), eq(forms.active, true)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Form not found or not active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = await ctx.db.query.participants.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(participants.studyId, form.studyId),
|
||||||
|
eq(participants.participantCode, participantCode),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Invalid participant code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResponse = await ctx.db.query.formResponses.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(formResponses.formId, formId),
|
||||||
|
eq(formResponses.participantId, participant.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingResponse) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "You have already submitted this form",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newResponse] = await ctx.db
|
||||||
|
.insert(formResponses)
|
||||||
|
.values({
|
||||||
|
formId,
|
||||||
|
participantId: participant.id,
|
||||||
|
responses,
|
||||||
|
status: "completed",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newResponse;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast trial status update
|
// Broadcast trial status update
|
||||||
await wsManager.broadcast(input.id, {
|
await wsManager.broadcastExternal(input.id, {
|
||||||
type: "trial_status",
|
type: "trial_status",
|
||||||
data: {
|
data: {
|
||||||
trial: trial[0],
|
trial: trial[0],
|
||||||
@@ -655,7 +655,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast trial status update
|
// Broadcast trial status update
|
||||||
await wsManager.broadcast(input.id, {
|
await wsManager.broadcastExternal(input.id, {
|
||||||
type: "trial_status",
|
type: "trial_status",
|
||||||
data: {
|
data: {
|
||||||
trial,
|
trial,
|
||||||
@@ -718,7 +718,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast trial status update
|
// Broadcast trial status update
|
||||||
await wsManager.broadcast(input.id, {
|
await wsManager.broadcastExternal(input.id, {
|
||||||
type: "trial_status",
|
type: "trial_status",
|
||||||
data: {
|
data: {
|
||||||
trial: trial[0],
|
trial: trial[0],
|
||||||
@@ -878,7 +878,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Broadcast new event to all subscribers
|
// Broadcast new event to all subscribers
|
||||||
await wsManager.broadcast(input.trialId, {
|
await wsManager.broadcastExternal(input.trialId, {
|
||||||
type: "trial_event",
|
type: "trial_event",
|
||||||
data: {
|
data: {
|
||||||
event,
|
event,
|
||||||
@@ -922,7 +922,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Broadcast intervention to all subscribers
|
// Broadcast intervention to all subscribers
|
||||||
await wsManager.broadcast(input.trialId, {
|
await wsManager.broadcastExternal(input.trialId, {
|
||||||
type: "intervention_logged",
|
type: "intervention_logged",
|
||||||
data: {
|
data: {
|
||||||
intervention,
|
intervention,
|
||||||
@@ -986,7 +986,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast annotation to all subscribers
|
// Broadcast annotation to all subscribers
|
||||||
await wsManager.broadcast(input.trialId, {
|
await wsManager.broadcastExternal(input.trialId, {
|
||||||
type: "annotation_added",
|
type: "annotation_added",
|
||||||
data: {
|
data: {
|
||||||
annotation,
|
annotation,
|
||||||
@@ -1380,7 +1380,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Broadcast robot action to all subscribers
|
// Broadcast robot action to all subscribers
|
||||||
await wsManager.broadcast(input.trialId, {
|
await wsManager.broadcastExternal(input.trialId, {
|
||||||
type: "trial_action_executed",
|
type: "trial_action_executed",
|
||||||
data: {
|
data: {
|
||||||
action_type: `${input.pluginName}.${input.actionId}`,
|
action_type: `${input.pluginName}.${input.actionId}`,
|
||||||
@@ -1439,7 +1439,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Broadcast robot action to all subscribers
|
// Broadcast robot action to all subscribers
|
||||||
await wsManager.broadcast(input.trialId, {
|
await wsManager.broadcastExternal(input.trialId, {
|
||||||
type: "trial_action_executed",
|
type: "trial_action_executed",
|
||||||
data: {
|
data: {
|
||||||
action_type: `${input.pluginName}.${input.actionId}`,
|
action_type: `${input.pluginName}.${input.actionId}`,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
export interface RobotCommunicationConfig {
|
export interface RobotCommunicationConfig {
|
||||||
rosBridgeUrl: string;
|
rosBridgeUrl: string;
|
||||||
@@ -22,6 +26,24 @@ export interface RobotAction {
|
|||||||
topic: string;
|
topic: string;
|
||||||
messageType: string;
|
messageType: string;
|
||||||
messageTemplate: Record<string, unknown>;
|
messageTemplate: Record<string, unknown>;
|
||||||
|
payloadMapping?: {
|
||||||
|
type?: string;
|
||||||
|
transformFn?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
sshCommand?: string;
|
||||||
|
};
|
||||||
|
ros2?: {
|
||||||
|
topic?: string;
|
||||||
|
messageType?: string;
|
||||||
|
service?: string;
|
||||||
|
action?: string;
|
||||||
|
payloadMapping?: {
|
||||||
|
type?: string;
|
||||||
|
transformFn?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
sshCommand?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,41 +183,71 @@ export class RobotCommunicationService extends EventEmitter {
|
|||||||
* Execute a robot action
|
* Execute a robot action
|
||||||
*/
|
*/
|
||||||
async executeAction(action: RobotAction): Promise<RobotActionResult> {
|
async executeAction(action: RobotAction): Promise<RobotActionResult> {
|
||||||
|
const actionId = `action_${this.messageId++}`;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Check if this is an SSH-only action (animations, posture, arbitrary SSH commands)
|
||||||
|
const { implementation, actionId: actionType } = action;
|
||||||
|
const baseActionId = actionType.includes(".")
|
||||||
|
? actionType.split(".").pop()
|
||||||
|
: actionType;
|
||||||
|
|
||||||
|
const isAnimationAction = baseActionId?.startsWith("play_animation_");
|
||||||
|
const sshCommand = implementation.payloadMapping?.sshCommand
|
||||||
|
|| implementation.ros2?.payloadMapping?.sshCommand;
|
||||||
|
|
||||||
|
// SSH actions don't require ROS connection
|
||||||
|
if (isAnimationAction || sshCommand) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
throw new Error(`SSH action timeout: ${action.actionId}`);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[RobotComm] Executing SSH action: ${action.actionId}`);
|
||||||
|
const result = await this.executeRobotActionInternal(action, actionId, startTime);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-SSH actions require ROS connection
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
throw new Error("Not connected to ROS bridge");
|
throw new Error("Not connected to ROS bridge");
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
// Store pending action
|
||||||
const actionId = `action_${this.messageId++}`;
|
const pending = {
|
||||||
|
resolve: (() => {}) as (result: RobotActionResult) => void,
|
||||||
return new Promise((resolve, reject) => {
|
reject: (() => {}) as (error: Error) => void,
|
||||||
// Set up timeout
|
timeout: setTimeout(() => {
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.pendingActions.delete(actionId);
|
this.pendingActions.delete(actionId);
|
||||||
reject(new Error(`Action timeout: ${action.actionId}`));
|
pending.reject(new Error(`Action timeout: ${action.actionId}`));
|
||||||
}, 30000); // 30 second timeout
|
}, 30000),
|
||||||
|
startTime,
|
||||||
|
};
|
||||||
|
|
||||||
// Store pending action
|
this.pendingActions.set(actionId, pending);
|
||||||
this.pendingActions.set(actionId, {
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
timeout,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
// Wrap the pending resolve/reject in a way that works with async method
|
||||||
// Log the action we're about to execute
|
return new Promise<RobotActionResult>((resolve, reject) => {
|
||||||
console.log(`[RobotComm] Executing robot action: ${action.actionId}`);
|
pending.resolve = resolve;
|
||||||
console.log(`[RobotComm] Topic: ${action.implementation.topic}`);
|
pending.reject = reject;
|
||||||
console.log(`[RobotComm] Parameters:`, action.parameters);
|
|
||||||
|
|
||||||
// Execute action based on type and platform
|
// Execute action
|
||||||
this.executeRobotActionInternal(action, actionId);
|
this.executeRobotActionInternal(action, actionId, startTime)
|
||||||
} catch (error) {
|
.then((result) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(pending.timeout);
|
||||||
this.pendingActions.delete(actionId);
|
this.pendingActions.delete(actionId);
|
||||||
reject(error);
|
resolve(result);
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingActions.delete(actionId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,17 +260,54 @@ export class RobotCommunicationService extends EventEmitter {
|
|||||||
|
|
||||||
// Private methods
|
// Private methods
|
||||||
|
|
||||||
private executeRobotActionInternal(
|
private async executeRobotActionInternal(
|
||||||
action: RobotAction,
|
action: RobotAction,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
): void {
|
startTime: number,
|
||||||
const { implementation, parameters } = action;
|
): Promise<RobotActionResult> {
|
||||||
|
const { implementation, parameters, actionId: actionType } = action;
|
||||||
|
|
||||||
// Build ROS message from template
|
// Use SSH for play_animation actions (check both namespaced and non-namespaced)
|
||||||
const message = this.buildRosMessage(
|
const baseActionId = actionType.includes(".")
|
||||||
implementation.messageTemplate,
|
? actionType.split(".").pop()
|
||||||
parameters,
|
: actionType;
|
||||||
);
|
|
||||||
|
if (baseActionId?.startsWith("play_animation_")) {
|
||||||
|
await this.executeAnimationViaSSH(baseActionId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: { method: "ssh", action: baseActionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for SSH command type
|
||||||
|
const sshCommand = implementation.payloadMapping?.sshCommand
|
||||||
|
|| implementation.ros2?.payloadMapping?.sshCommand;
|
||||||
|
|
||||||
|
if (sshCommand) {
|
||||||
|
await this.executeSSHCommand(sshCommand);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: { method: "ssh", command: sshCommand },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply transform if specified
|
||||||
|
let message: Record<string, unknown>;
|
||||||
|
const transformFn = implementation.payloadMapping?.transformFn
|
||||||
|
|| implementation.ros2?.payloadMapping?.transformFn;
|
||||||
|
|
||||||
|
if (transformFn) {
|
||||||
|
message = this.applyTransform(transformFn, parameters);
|
||||||
|
} else {
|
||||||
|
// Build ROS message from template
|
||||||
|
message = this.buildRosMessage(
|
||||||
|
implementation.messageTemplate,
|
||||||
|
parameters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Publish to ROS topic
|
// Publish to ROS topic
|
||||||
this.publishToTopic(
|
this.publishToTopic(
|
||||||
@@ -229,19 +318,94 @@ export class RobotCommunicationService extends EventEmitter {
|
|||||||
|
|
||||||
// For actions that complete immediately (like movement commands),
|
// For actions that complete immediately (like movement commands),
|
||||||
// we simulate completion after a short delay
|
// we simulate completion after a short delay
|
||||||
setTimeout(() => {
|
return new Promise((resolve) => {
|
||||||
this.completeAction(actionId, {
|
setTimeout(() => {
|
||||||
success: true,
|
resolve({
|
||||||
duration:
|
success: true,
|
||||||
Date.now() -
|
duration: Date.now() - startTime,
|
||||||
(this.pendingActions.get(actionId)?.startTime || Date.now()),
|
data: {
|
||||||
data: {
|
topic: implementation.topic,
|
||||||
topic: implementation.topic,
|
messageType: implementation.messageType,
|
||||||
messageType: implementation.messageType,
|
message,
|
||||||
message,
|
},
|
||||||
},
|
});
|
||||||
});
|
}, 100);
|
||||||
}, 100);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeSSHCommand(command: string): Promise<void> {
|
||||||
|
const robotIp = process.env.NAO_IP || "134.82.159.168";
|
||||||
|
const password = process.env.NAO_PASSWORD || "robolab";
|
||||||
|
|
||||||
|
console.log(`[RobotComm] Executing SSH command: ${command}`);
|
||||||
|
|
||||||
|
const sshCommand = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execAsync(sshCommand);
|
||||||
|
|
||||||
|
if (stderr && !stderr.includes("null") && stderr.trim()) {
|
||||||
|
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RobotComm] SSH result: ${stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeAnimationViaSSH(actionType: string): Promise<void> {
|
||||||
|
const animationMap: Record<string, string> = {
|
||||||
|
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
|
||||||
|
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
|
||||||
|
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
|
||||||
|
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
|
||||||
|
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
|
||||||
|
"play_animation_no": "animations/Stand/Gestures/No_3",
|
||||||
|
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const animation = animationMap[actionType];
|
||||||
|
if (!animation) {
|
||||||
|
throw new Error(`Unknown animation: ${actionType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const robotIp = process.env.NAO_IP || "134.82.159.168";
|
||||||
|
const password = process.env.NAO_PASSWORD || "robolab";
|
||||||
|
|
||||||
|
console.log(`[RobotComm] Executing animation via SSH: ${animation}`);
|
||||||
|
|
||||||
|
const command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "nao@${robotIp}" "qicli call ALAnimationPlayer.run '${animation}'"`;
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr && !stderr.includes("null")) {
|
||||||
|
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RobotComm] Animation result: ${stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformToEmotionalSpeech(parameters: Record<string, unknown>): { data: string } {
|
||||||
|
const text = String(parameters.text || "Hello");
|
||||||
|
const emotion = String(parameters.emotion || "neutral");
|
||||||
|
|
||||||
|
switch (emotion) {
|
||||||
|
case "happy":
|
||||||
|
return { data: `\\rspd=120\\ ${text}` };
|
||||||
|
case "sad":
|
||||||
|
return { data: `\\rspd=80\\ ${text}` };
|
||||||
|
case "neutral":
|
||||||
|
default:
|
||||||
|
return { data: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTransform(transformFn: string, parameters: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
switch (transformFn) {
|
||||||
|
case "transformToEmotionalSpeech":
|
||||||
|
case "transformToEmotionSpeech":
|
||||||
|
return this.transformToEmotionalSpeech(parameters);
|
||||||
|
default:
|
||||||
|
console.warn(`[RobotComm] Unknown transform: ${transformFn}`);
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRosMessage(
|
private buildRosMessage(
|
||||||
|
|||||||
@@ -441,10 +441,6 @@ export class TrialExecutionEngine {
|
|||||||
case "hristudio-core.loop":
|
case "hristudio-core.loop":
|
||||||
return await this.executeLoopAction(trialId, action);
|
return await this.executeLoopAction(trialId, action);
|
||||||
|
|
||||||
case "branch":
|
|
||||||
case "hristudio-core.branch":
|
|
||||||
return await this.executeBranchAction(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 (
|
if (
|
||||||
@@ -799,8 +795,18 @@ export class TrialExecutionEngine {
|
|||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
trialId: string,
|
trialId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Ensure robot communication service is available
|
// Plugin JSON uses a top-level "ros2" key; fall back to it if "implementation" is absent
|
||||||
if (!this.robotComm.getConnectionStatus()) {
|
const impl = actionDefinition.implementation ?? actionDefinition.ros2;
|
||||||
|
|
||||||
|
// Determine if this action uses SSH (animations or explicit sshCommand)
|
||||||
|
const sshCommand =
|
||||||
|
impl?.payloadMapping?.sshCommand ||
|
||||||
|
impl?.ros2?.payloadMapping?.sshCommand;
|
||||||
|
const isSSHAction =
|
||||||
|
actionDefinition.id?.startsWith("play_animation_") || !!sshCommand;
|
||||||
|
|
||||||
|
// SSH actions bypass ROS bridge — only connect for ROS-dependent actions
|
||||||
|
if (!isSSHAction && !this.robotComm.getConnectionStatus()) {
|
||||||
try {
|
try {
|
||||||
await this.robotComm.connect();
|
await this.robotComm.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -813,9 +819,9 @@ export class TrialExecutionEngine {
|
|||||||
// Prepare robot action
|
// Prepare robot action
|
||||||
const robotAction: RobotAction = {
|
const robotAction: RobotAction = {
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
actionId: actionDefinition.id,
|
actionId: actionDefinition.id, // e.g., "play_animation_yes"
|
||||||
parameters,
|
parameters,
|
||||||
implementation: actionDefinition.implementation,
|
implementation: impl,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute action through robot communication service
|
// Execute action through robot communication service
|
||||||
|
|||||||
@@ -146,6 +146,24 @@ class WebSocketManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called from Next.js tRPC router — POSTs to the Bun ws-server process
|
||||||
|
// which holds the actual client connections.
|
||||||
|
async broadcastExternal(
|
||||||
|
trialId: string,
|
||||||
|
message: OutgoingMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
const wsPort = process.env.WS_PORT ?? "3001";
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:${wsPort}/internal/broadcast`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ trialId, message }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WS] Failed to broadcast externally for trial ${trialId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async broadcastToAll(message: OutgoingMessage): Promise<void> {
|
async broadcastToAll(message: OutgoingMessage): Promise<void> {
|
||||||
const messageStr = JSON.stringify(message);
|
const messageStr = JSON.stringify(message);
|
||||||
const disconnectedClients: string[] = [];
|
const disconnectedClients: string[] = [];
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
],
|
],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
// "baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": [
|
"~/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
|
|||||||
+17
-4
@@ -46,9 +46,22 @@ console.log(`Starting WebSocket server on port ${port}...`);
|
|||||||
|
|
||||||
serve<WSData>({
|
serve<WSData>({
|
||||||
port,
|
port,
|
||||||
fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Internal broadcast endpoint — called by Next.js tRPC router
|
||||||
|
if (url.pathname === "/internal/broadcast") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
const { trialId, message } = (await req.json()) as {
|
||||||
|
trialId: string;
|
||||||
|
message: { type: string; data: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
await wsManager.broadcast(trialId, message);
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/websocket") {
|
if (url.pathname === "/api/websocket") {
|
||||||
if (req.headers.get("upgrade") !== "websocket") {
|
if (req.headers.get("upgrade") !== "websocket") {
|
||||||
return new Response("WebSocket upgrade required", { status: 426 });
|
return new Response("WebSocket upgrade required", { status: 426 });
|
||||||
@@ -114,7 +127,7 @@ serve<WSData>({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
message(ws: ServerWebSocket<WSData>, message) {
|
async message(ws: ServerWebSocket<WSData>, message) {
|
||||||
const { clientId, trialId } = ws.data;
|
const { clientId, trialId } = ws.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +144,7 @@ serve<WSData>({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "request_trial_status": {
|
case "request_trial_status": {
|
||||||
const status = wsManager.getTrialStatusSync(trialId);
|
const status = await wsManager.getTrialStatus(trialId);
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "trial_status",
|
type: "trial_status",
|
||||||
@@ -146,7 +159,7 @@ serve<WSData>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "request_trial_events": {
|
case "request_trial_events": {
|
||||||
const events = wsManager.getTrialEventsSync(
|
const events = await wsManager.getTrialEvents(
|
||||||
trialId,
|
trialId,
|
||||||
msg.data?.limit ?? 100,
|
msg.data?.limit ?? 100,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user