From 5dfc25ec83a4b7b136abc539e3078f29b99e043d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 25 Jun 2026 16:46:27 +0000 Subject: [PATCH] TNA Tool V1 --- backend/src/index.js | 3 + backend/src/routes/interviewWorkflow.js | 265 +++++++++++++++ frontend/src/components/AsideMenuLayer.tsx | 3 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 266 +++++++-------- frontend/src/pages/interview-workspace.tsx | 369 +++++++++++++++++++++ frontend/src/pages/search.tsx | 4 +- 9 files changed, 762 insertions(+), 162 deletions(-) create mode 100644 backend/src/routes/interviewWorkflow.js create mode 100644 frontend/src/pages/interview-workspace.tsx diff --git a/backend/src/index.js b/backend/src/index.js index 584a936..c2d3447 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -54,6 +54,7 @@ const tagsRoutes = require('./routes/tags'); const interview_tagsRoutes = require('./routes/interview_tags'); const reportsRoutes = require('./routes/reports'); +const interviewWorkflowRoutes = require('./routes/interviewWorkflow'); const getBaseUrl = (url) => { @@ -145,6 +146,8 @@ app.use('/api/interview_tags', passport.authenticate('jwt', {session: false}), i app.use('/api/reports', passport.authenticate('jwt', {session: false}), reportsRoutes); +app.use('/api/interview-workflow', passport.authenticate('jwt', {session: false}), interviewWorkflowRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/interviewWorkflow.js b/backend/src/routes/interviewWorkflow.js new file mode 100644 index 0000000..e8650c2 --- /dev/null +++ b/backend/src/routes/interviewWorkflow.js @@ -0,0 +1,265 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const INTERVIEW_QUESTIONS = [ + 'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?', + 'Describe training that is most effective for your learning and why.', + 'What is a reasonable amount of time that you could dedicate to training?', + 'What makes learning memorable for you? How can training be designed to help stick for you?', + 'How do you learn about training opportunities? Is that method effective? What could make it more effective?', +]; + +const SECTION_BY_QUESTION = [ + 'Participant priorities and interests', + 'Preferred learning methods', + 'Time available for training', + 'What makes training memorable and effective', + 'How the participant hears about training opportunities', +]; + +const FINDING_TYPES = [ + 'priority_interest', + 'preferred_method', + 'time_constraint', + 'implication', + 'channel_preference', +]; + +const normalizeAnswers = (answers) => { + if (!Array.isArray(answers)) return []; + + return INTERVIEW_QUESTIONS.map((question, index) => { + const answer = answers[index] || {}; + return { + question, + answer: String(answer.answer || '').trim(), + }; + }); +}; + +const sentenceFrom = (text) => { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + if (!clean) return 'No specific response captured.'; + return clean.length > 220 ? `${clean.slice(0, 217)}...` : clean; +}; + +const extractTags = (answers) => { + const text = answers.map((item) => item.answer).join(' ').toLowerCase(); + const tagRules = [ + ['hands-on learning', ['hands-on', 'practice', 'workshop', 'exercise', 'interactive']], + ['short sessions', ['short', 'bite', 'micro', 'hour', '30', 'minutes']], + ['peer learning', ['peer', 'colleague', 'mentor', 'community', 'team']], + ['role-specific training', ['role', 'job', 'specific', 'relevant', 'real-world']], + ['communication channels', ['email', 'newsletter', 'calendar', 'intranet', 'slack', 'teams']], + ['time constraints', ['busy', 'time', 'schedule', 'workload', 'capacity']], + ['follow-up support', ['follow-up', 'refresh', 'reinforce', 'coaching', 'support']], + ]; + + const tags = tagRules + .filter(([, terms]) => terms.some((term) => text.includes(term))) + .map(([tag]) => tag); + + return tags.length ? tags : ['training needs assessment']; +}; + +const buildSummary = (answers, mode) => { + const lines = SECTION_BY_QUESTION.map((section, index) => `- ${section}: ${sentenceFrom(answers[index]?.answer)}`); + const gaps = answers + .map((item) => item.answer) + .join(' ') + .toLowerCase() + .match(/gap|need|hard|difficult|barrier|challenge|limited|lack/g); + + lines.push(`- Notable training gaps, barriers, or unmet needs: ${gaps ? 'Potential unmet needs or barriers were mentioned and should be reviewed in context.' : 'No explicit gap or barrier language was captured in this first-pass summary.'}`); + lines.push(`- Recommended implications for future training design: Prioritize practical, easy-to-access training formats that reflect the participant's stated preferences and constraints.`); + lines.push(`- Interview mode: ${mode === 'voice' ? 'Voice or dictated responses' : mode === 'typed' ? 'Typed responses' : 'Mixed mode'}.`); + + return lines.join('\n'); +}; + +const organizationWhere = (currentUser) => { + if (currentUser.app_role?.globalAccess) return {}; + const organizationId = currentUser.organization?.id || currentUser.organizationId; + return organizationId ? { organizationId } : {}; +}; + +router.get('/records', wrapAsync(async (req, res) => { + const limit = Math.min(Number(req.query.limit) || 20, 50); + const rows = await db.interviews.findAll({ + where: organizationWhere(req.currentUser), + include: [ + { model: db.participants, as: 'participant' }, + { model: db.users, as: 'facilitator' }, + ], + order: [['createdAt', 'DESC']], + limit, + }); + + res.status(200).send({ rows }); +})); + +router.get('/analysis', wrapAsync(async (req, res) => { + const interviewWhere = organizationWhere(req.currentUser); + const interviews = await db.interviews.findAll({ + where: interviewWhere, + include: [ + { model: db.participants, as: 'participant' }, + { model: db.interview_findings, as: 'interview_findings_interview' }, + ], + order: [['createdAt', 'DESC']], + limit: 100, + }); + + const findingCounts = {}; + const tagCounts = {}; + + interviews.forEach((interview) => { + (interview.interview_findings_interview || []).forEach((finding) => { + const type = finding.finding_type || 'uncategorized'; + findingCounts[type] = (findingCounts[type] || 0) + 1; + }); + + try { + const parsed = JSON.parse(interview.inference_notes || '{}'); + (parsed.tags || []).forEach((tag) => { + tagCounts[tag] = (tagCounts[tag] || 0) + 1; + }); + } catch (error) { + console.error('Failed to parse interview inference notes', error); + } + }); + + const themes = Object.entries(tagCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([theme, count]) => ({ theme, count })); + + res.status(200).send({ + interviewCount: interviews.length, + findingCounts, + themes, + latestSummaries: interviews.slice(0, 5).map((interview) => ({ + id: interview.id, + participant: interview.participant?.display_name || 'Unnamed participant', + final_summary: interview.final_summary, + createdAt: interview.createdAt, + })), + }); +})); + +router.get('/records/:id', wrapAsync(async (req, res) => { + const interview = await db.interviews.findOne({ + where: { id: req.params.id, ...organizationWhere(req.currentUser) }, + include: [ + { model: db.participants, as: 'participant' }, + { model: db.users, as: 'facilitator' }, + { model: db.interview_responses, as: 'interview_responses_interview' }, + { model: db.interview_findings, as: 'interview_findings_interview' }, + ], + }); + + if (!interview) { + return res.status(404).send('Interview record not found'); + } + + return res.status(200).send(interview); +})); + +router.post('/records', wrapAsync(async (req, res) => { + const mode = req.body.mode; + const participant = req.body.participant || {}; + const answers = normalizeAnswers(req.body.answers); + + if (!['voice', 'typed', 'mixed'].includes(mode)) { + return res.status(400).send('Choose voice, typed, or mixed interview mode.'); + } + + const missingAnswer = answers.find((item) => !item.answer); + if (missingAnswer) { + return res.status(400).send('Please capture a response for every core interview question before saving.'); + } + + const organizationId = req.currentUser.organization?.id || req.currentUser.organizationId || null; + const transaction = await db.sequelize.transaction(); + + try { + const createdParticipant = await db.participants.create({ + display_name: String(participant.display_name || '').trim() || `Participant ${new Date().toISOString().slice(0, 10)}`, + department: String(participant.department || '').trim() || null, + job_title: String(participant.job_title || '').trim() || null, + notes: String(participant.notes || '').trim() || null, + consent_to_store: true, + organizationId, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + const tags = extractTags(answers); + const finalSummary = buildSummary(answers, mode); + const inferenceNotes = JSON.stringify({ + objective: 'Understand current practices, training gaps, and priority skill areas across workforce interviews.', + tags, + generated_by: 'rule_based_mvp_summary', + }); + + const interview = await db.interviews.create({ + started_at: new Date(), + completed_at: new Date(), + mode, + status: 'completed', + context_notes: String(req.body.context_notes || '').trim() || 'Fixed five-question training needs assessment interview.', + final_summary: finalSummary, + inference_notes: inferenceNotes, + organizationId, + participantId: createdParticipant.id, + facilitatorId: req.currentUser.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + await Promise.all(answers.map((item, index) => db.interview_responses.create({ + turn_number: index + 1, + response_format: mode === 'voice' ? 'spoken_style' : 'typed', + response_text: `Question: ${item.question}\n\nResponse: ${item.answer}`, + is_clarification: false, + interviewId: interview.id, + organizationsId: organizationId, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }))); + + await Promise.all(answers.map((item, index) => db.interview_findings.create({ + finding_type: FINDING_TYPES[index] || 'implication', + sentiment: 'neutral', + confidence: 0.7, + evidence_excerpt: sentenceFrom(item.answer), + notes: `${SECTION_BY_QUESTION[index]}: ${sentenceFrom(item.answer)}`, + interviewId: interview.id, + organizationsId: organizationId, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }))); + + await transaction.commit(); + + const savedInterview = await db.interviews.findByPk(interview.id, { + include: [ + { model: db.participants, as: 'participant' }, + { model: db.interview_responses, as: 'interview_responses_interview' }, + { model: db.interview_findings, as: 'interview_findings_interview' }, + ], + }); + + return res.status(201).send(savedInterview); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 8abf844..f24b99a 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index cd66fc8..b3ce20b 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -72,6 +72,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_PARTICIPANTS' }, + { + href: '/interview-workspace', + label: 'Interview Workspace', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiClipboardTextClockOutline' in icon ? icon['mdiClipboardTextClockOutline' as keyof typeof icon] : icon.mdiAccountVoice ?? icon.mdiTable, + permissions: 'READ_INTERVIEWS' + }, { href: '/interviews/interviews-list', label: 'Interviews', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 2b01517..9e972af 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,126 @@ +import { mdiAccountVoice, mdiLoginVariant } from '@mdi/js' +import type { ReactElement } from 'react' +import Head from 'next/head' +import Link from 'next/link' +import React from 'react' +import BaseButton from '../components/BaseButton' +import LayoutGuest from '../layouts/Guest' +import { getPageTitle } from '../config' -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; - +const workflowCards = [ + { + title: 'Choose voice or typed mode', + text: 'Start every session by confirming how the participant wants to respond, then keep the flow interview-like and focused.', + }, + { + title: 'Capture the fixed five questions', + text: 'One main question appears at a time, with room for concise follow-up detail when it clarifies training needs.', + }, + { + title: 'Save summaries and findings', + text: 'Each completed interview becomes a durable record with structured responses, a concise summary, and reusable tags.', + }, +] export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Training Interview Tracker' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('Training needs interviews')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy +
+ + T + + TNA Studio + Training Interview Tracker + -
+ + +
+
+
+
Structured interviews, durable insight
+

Turn training conversations into clear workforce priorities.

+

Run consistent training-needs assessment interviews, capture voice-style or typed responses, produce grounded summaries, and retain structured findings for trend analysis across participants.

+
+ + +
+

Authentication keeps interview records private. Use the admin interface to access the working MVP slice.

+
+ +
+
+
+
+
+
+
+

Live session

+

Question 2 of 5

+
+ Typed mode +
+
+

Main question

+

Describe training that is most effective for your learning and why.

+
+
+

Captured answer becomes part of the participant summary, finding tags, and future cross-interview pattern analysis.

+
+
+
+
+
+ +
+
+
+

First MVP workflow

+

A thin slice from interview start to analysis-ready record.

+
+
+ {workflowCards.map((card, index) => ( +
+
{index + 1}
+

{card.title}

+

{card.text}

+
+ ))} +
+
+
+ +
+
+

5

+

Core questions kept consistent across interviews.

+
+
+

≤60m

+

Designed to keep facilitation moving without becoming a long survey.

+
+
+

Trends

+

Themes, recurring gaps, delivery preferences, and communication channels accumulate over time.

+
+
+
+ +
+ © 2026 Training Interview Tracker · Privacy Policy +
- ); + ) } Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - + return {page} +} diff --git a/frontend/src/pages/interview-workspace.tsx b/frontend/src/pages/interview-workspace.tsx new file mode 100644 index 0000000..dd598f0 --- /dev/null +++ b/frontend/src/pages/interview-workspace.tsx @@ -0,0 +1,369 @@ +import { mdiAccountVoice, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiClipboardTextOutline, mdiMicrophoneOutline, mdiPencilOutline } from '@mdi/js' +import axios from 'axios' +import Head from 'next/head' +import React, { ReactElement, useEffect, useMemo, useState } from 'react' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import LayoutAuthenticated from '../layouts/Authenticated' +import { getPageTitle } from '../config' +import { useAppSelector } from '../stores/hooks' + +type InterviewMode = 'voice' | 'typed' | 'mixed' + +type SavedInterview = { + id: string + mode?: InterviewMode + status?: string + final_summary?: string + createdAt?: string + completed_at?: string + participant?: { + display_name?: string + department?: string + job_title?: string + } + interview_responses_interview?: Array<{ + id: string + turn_number: number + response_text: string + }> +} + +type Analysis = { + interviewCount: number + findingCounts: Record + themes: Array<{ theme: string; count: number }> +} + +const QUESTIONS = [ + 'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?', + 'Describe training that is most effective for your learning and why.', + 'What is a reasonable amount of time that you could dedicate to training?', + 'What makes learning memorable for you? How can training be designed to help stick for you?', + 'How do you learn about training opportunities? Is that method effective? What could make it more effective?', +] + +const SECTION_TITLES = [ + 'Participant priorities and interests', + 'Preferred learning methods', + 'Time available for training', + 'Memorable and effective learning', + 'Training opportunity channels', +] + +const emptyAnswers = QUESTIONS.map(() => ({ answer: '' })) + +const inputClass = 'w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-800 shadow-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-900 dark:text-slate-100' +const labelClass = 'mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400' + +const responseOnly = (responseText: string) => responseText.split('Response:').pop()?.trim() || responseText + +const InterviewWorkspacePage = () => { + const { token } = useAppSelector((state) => state.auth) + const [mode, setMode] = useState('') + const [participant, setParticipant] = useState({ display_name: '', department: '', job_title: '', notes: '' }) + const [answers, setAnswers] = useState(emptyAnswers) + const [activeQuestion, setActiveQuestion] = useState(0) + const [records, setRecords] = useState([]) + const [selectedRecord, setSelectedRecord] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [notice, setNotice] = useState('') + const [error, setError] = useState('') + + const answeredCount = useMemo(() => answers.filter((item) => item.answer.trim()).length, [answers]) + const canSave = Boolean(mode) && answeredCount === QUESTIONS.length && !isSaving + + const fetchRecords = async () => { + setIsLoading(true) + try { + const [recordsResponse, analysisResponse] = await Promise.all([ + axios.get('/interview-workflow/records'), + axios.get('/interview-workflow/analysis'), + ]) + const fetchedRecords = recordsResponse.data?.rows || [] + setRecords(fetchedRecords) + setAnalysis(analysisResponse.data) + setSelectedRecord((current) => current || fetchedRecords[0] || null) + } catch (fetchError) { + if (axios.isAxiosError(fetchError) && fetchError.response?.status === 401) { + setError('Please log in to load the interview workspace.') + return + } + console.error('Failed to load interview workspace data', fetchError) + setError('Could not load saved interview records. Please refresh and try again.') + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (token) { + fetchRecords() + } + }, [token]) + + const updateAnswer = (value: string) => { + setAnswers((current) => current.map((item, index) => (index === activeQuestion ? { answer: value } : item))) + } + + const resetInterview = () => { + setMode('') + setParticipant({ display_name: '', department: '', job_title: '', notes: '' }) + setAnswers(emptyAnswers) + setActiveQuestion(0) + setNotice('Ready for the next interview. Choose a response mode to begin.') + setError('') + } + + const saveInterview = async () => { + if (!canSave || !mode) return + + setIsSaving(true) + setError('') + setNotice('') + + try { + const response = await axios.post('/interview-workflow/records', { + mode, + participant, + answers, + }) + setNotice('Interview saved. A structured summary and findings were added to the repository.') + setSelectedRecord(response.data) + setRecords((current) => [response.data, ...current.filter((record) => record.id !== response.data.id)]) + await fetchRecords() + } catch (saveError) { + console.error('Failed to save interview record', saveError) + setError(axios.isAxiosError(saveError) && saveError.response?.data ? String(saveError.response.data) : 'Could not save the interview. Please review the responses and try again.') + } finally { + setIsSaving(false) + } + } + + const selectedResponses = [...(selectedRecord?.interview_responses_interview || [])].sort((a, b) => a.turn_number - b.turn_number) + + return ( + <> + + {getPageTitle('Interview workspace')} + + + + + + +
+
+
+

Facilitated assessment flow

+

Run the interview, capture every answer, and keep findings ready for trend analysis.

+

This workspace keeps the fixed five-question script on pace, saves a concise participant summary, and extracts reusable finding tags for future cross-interview analysis.

+
+
+
+

{records.length}

+

Saved

+
+
+

{answeredCount}/5

+

Current

+
+
+

≤60

+

Minutes

+
+
+
+
+ + {notice &&
{notice}
} + {error &&
{error}
} + +
+ +
+
+
+

Step 1

+

Choose the session mode

+

Ask this first and keep the mode for the session unless the participant switches.

+
+
+ + +
+
+
+ +
+
+
+ + setParticipant({ ...participant, display_name: event.target.value })} placeholder="Optional, e.g. Participant A or HR manager" /> +
+
+
+ + setParticipant({ ...participant, department: event.target.value })} placeholder="Optional" /> +
+
+ + setParticipant({ ...participant, job_title: event.target.value })} placeholder="Optional" /> +
+
+
+

Research objective

+

Understand current practices, training gaps, and priority skill areas so training can be delivered in formats that support real-world application.

+
+
+ +
+
+
+

Question {activeQuestion + 1} of {QUESTIONS.length}

+

{SECTION_TITLES[activeQuestion]}

+
+
{mode || 'mode?'}
+
+ +
+

{mode === 'voice' ? Voice prompt : Main question}

+

{QUESTIONS[activeQuestion]}

+ {mode === 'voice' &&

Read this naturally, then capture the participant's spoken or dictated answer below.

} +
+ +