diff --git a/backend/src/routes/surveys.js b/backend/src/routes/surveys.js
index 043a2bf..c35f40e 100644
--- a/backend/src/routes/surveys.js
+++ b/backend/src/routes/surveys.js
@@ -3,10 +3,9 @@ const express = require('express');
const SurveysService = require('../services/surveys');
const SurveysDBApi = require('../db/api/surveys');
+const PulseSurveyLaunchpadService = require('../services/pulseSurveyLaunchpad');
const wrapAsync = require('../helpers').wrapAsync;
-const config = require('../config');
-
const router = express.Router();
@@ -263,6 +262,21 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
+router.get('/launchpad/summary', wrapAsync(async (req, res) => {
+ const payload = await PulseSurveyLaunchpadService.getLaunchpadSummary(req.currentUser);
+ res.status(200).send(payload);
+}));
+
+router.post('/launchpad/create', wrapAsync(async (req, res) => {
+ const payload = await PulseSurveyLaunchpadService.createSurveyFromTemplate(req.body, req.currentUser);
+ res.status(200).send(payload);
+}));
+
+router.post('/:id/pulse-analysis', wrapAsync(async (req, res) => {
+ const payload = await PulseSurveyLaunchpadService.generateSurveyAnalysis(req.params.id, req.currentUser);
+ res.status(200).send(payload);
+}));
+
/**
* @swagger
* /api/surveys:
diff --git a/backend/src/services/pulseSurveyLaunchpad.js b/backend/src/services/pulseSurveyLaunchpad.js
new file mode 100644
index 0000000..0117bd7
--- /dev/null
+++ b/backend/src/services/pulseSurveyLaunchpad.js
@@ -0,0 +1,772 @@
+const db = require('../db/models');
+const SurveysDBApi = require('../db/api/surveys');
+const SurveyQuestionsDBApi = require('../db/api/survey_questions');
+const QuestionChoicesDBApi = require('../db/api/question_choices');
+const AiAnalysesDBApi = require('../db/api/ai_analyses');
+const { LocalAIApi } = require('../ai/LocalAIApi');
+
+const { Op } = db.Sequelize;
+
+const TEMPLATE_LIBRARY = {
+ weekly_pulse: {
+ key: 'weekly_pulse',
+ title: 'Weekly Pulse',
+ accent: '#8B5CF6',
+ description:
+ 'A fast weekly check-in for morale, momentum, and manager support.',
+ defaultCadence: 'weekly',
+ defaultVisibility: 'anonymous',
+ questions: [
+ {
+ prompt: 'How energized did you feel about work this week?',
+ question_type: 'rating_1_10',
+ min_value: 1,
+ max_value: 10,
+ is_required: true,
+ help_text: '1 = drained, 10 = energized and focused',
+ },
+ {
+ prompt: 'What best describes your workload right now?',
+ question_type: 'multiple_choice',
+ is_required: true,
+ choices: [
+ 'Very manageable',
+ 'Healthy stretch',
+ 'Mostly too much',
+ 'Unsustainably high',
+ ],
+ },
+ {
+ prompt: 'How supported do you feel by your manager this week?',
+ question_type: 'emoji_reaction',
+ is_required: true,
+ help_text: 'Choose the emoji that best matches your experience.',
+ },
+ {
+ prompt: 'What is one thing we should keep, stop, or improve next week?',
+ question_type: 'open_text',
+ is_required: false,
+ },
+ ],
+ },
+ onboarding: {
+ key: 'onboarding',
+ title: 'Onboarding Check-In',
+ accent: '#14B8A6',
+ description:
+ 'A first-month experience survey to spot clarity gaps and support needs early.',
+ defaultCadence: 'one_time',
+ defaultVisibility: 'identified',
+ questions: [
+ {
+ prompt: 'How clear is your role, team, and success criteria so far?',
+ question_type: 'rating_1_10',
+ min_value: 1,
+ max_value: 10,
+ is_required: true,
+ },
+ {
+ prompt: 'Which part of onboarding has been most helpful?',
+ question_type: 'multiple_choice',
+ is_required: true,
+ choices: ['Manager support', 'Team introductions', 'Documentation', 'Training sessions'],
+ },
+ {
+ prompt: 'How welcome do you feel on your team?',
+ question_type: 'emoji_reaction',
+ is_required: true,
+ },
+ {
+ prompt: 'What would have made your first weeks smoother?',
+ question_type: 'open_text',
+ is_required: false,
+ },
+ ],
+ },
+ quarterly_deep_dive: {
+ key: 'quarterly_deep_dive',
+ title: 'Quarterly Deep Dive',
+ accent: '#F97316',
+ description:
+ 'A richer quarterly engagement survey covering strategy, growth, and trust.',
+ defaultCadence: 'quarterly',
+ defaultVisibility: 'anonymous',
+ questions: [
+ {
+ prompt: 'How connected do you feel to the company strategy this quarter?',
+ question_type: 'rating_1_10',
+ min_value: 1,
+ max_value: 10,
+ is_required: true,
+ },
+ {
+ prompt: 'Which area needs the most attention this quarter?',
+ question_type: 'multiple_choice',
+ is_required: true,
+ choices: ['Recognition', 'Career growth', 'Communication', 'Workload balance'],
+ },
+ {
+ prompt: 'How would you describe team collaboration lately?',
+ question_type: 'emoji_reaction',
+ is_required: true,
+ },
+ {
+ prompt: 'What is the biggest blocker to high performance right now?',
+ question_type: 'open_text',
+ is_required: false,
+ },
+ ],
+ },
+ burnout_check: {
+ key: 'burnout_check',
+ title: 'Burnout Check',
+ accent: '#EF4444',
+ description:
+ 'A targeted pulse to surface overload, recovery signals, and department risk.',
+ defaultCadence: 'biweekly',
+ defaultVisibility: 'anonymous',
+ questions: [
+ {
+ prompt: 'How sustainable has your workload felt over the last two weeks?',
+ question_type: 'rating_1_10',
+ min_value: 1,
+ max_value: 10,
+ is_required: true,
+ help_text: '1 = not sustainable, 10 = very sustainable',
+ },
+ {
+ prompt: 'What is contributing most to stress right now?',
+ question_type: 'multiple_choice',
+ is_required: true,
+ choices: ['Too many priorities', 'Lack of clarity', 'Meetings/context switching', 'Personal energy'],
+ },
+ {
+ prompt: 'How are you feeling at the end of most workdays?',
+ question_type: 'emoji_reaction',
+ is_required: true,
+ },
+ {
+ prompt: 'What would most help you recover energy next sprint?',
+ question_type: 'open_text',
+ is_required: false,
+ },
+ ],
+ },
+};
+
+const EMOJI_TO_SCORE = {
+ very_bad: 1,
+ bad: 3,
+ neutral: 5,
+ good: 8,
+ very_good: 10,
+};
+
+const ACTIVE_STATUSES = ['scheduled', 'sending', 'active'];
+
+function buildError(message, code = 400) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function monthKey(date) {
+ const year = date.getUTCFullYear();
+ const month = `${date.getUTCMonth() + 1}`.padStart(2, '0');
+ return `${year}-${month}`;
+}
+
+function monthLabel(date) {
+ return date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
+}
+
+function parseJson(value, fallback = []) {
+ if (!value) {
+ return fallback;
+ }
+
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) || typeof parsed === 'object' ? parsed : fallback;
+ } catch (error) {
+ return fallback;
+ }
+}
+
+module.exports = class PulseSurveyLaunchpadService {
+ static listTemplates() {
+ return Object.values(TEMPLATE_LIBRARY).map((template) => ({
+ key: template.key,
+ title: template.title,
+ accent: template.accent,
+ description: template.description,
+ defaultCadence: template.defaultCadence,
+ defaultVisibility: template.defaultVisibility,
+ questionCount: template.questions.length,
+ previewQuestions: template.questions.map((question) => ({
+ prompt: question.prompt,
+ question_type: question.question_type,
+ })),
+ }));
+ }
+
+ static resolveTemplate(templateKey) {
+ return TEMPLATE_LIBRARY[templateKey] || null;
+ }
+
+ static async getLaunchpadSummary(currentUser) {
+ const organizationFilter = currentUser?.organizationsId
+ ? { organizationsId: currentUser.organizationsId }
+ : {};
+
+ const [
+ totalSurveys,
+ activeSurveys,
+ totalResponses,
+ totalInvitations,
+ aiReadyCount,
+ recentSurveys,
+ departmentRows,
+ responseRows,
+ ] = await Promise.all([
+ db.surveys.count({ where: organizationFilter }),
+ db.surveys.count({ where: { ...organizationFilter, status: { [Op.in]: ACTIVE_STATUSES } } }),
+ db.survey_responses.count({ where: organizationFilter }),
+ db.survey_invitations.count({ where: organizationFilter }),
+ db.survey_responses.count({
+ where: { ...organizationFilter, is_complete: true },
+ distinct: true,
+ col: 'surveyId',
+ }),
+ db.surveys.findAll({
+ where: organizationFilter,
+ include: [
+ {
+ model: db.survey_templates,
+ as: 'template',
+ },
+ ],
+ order: [['createdAt', 'DESC']],
+ limit: 6,
+ }),
+ db.departments.findAll({
+ where: organizationFilter,
+ order: [['name', 'ASC']],
+ raw: true,
+ }),
+ db.survey_responses.findAll({
+ where: {
+ ...organizationFilter,
+ submitted_at: {
+ [Op.gte]: new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 5)),
+ },
+ },
+ attributes: ['submitted_at'],
+ raw: true,
+ }),
+ ]);
+
+ const recentSurveyIds = recentSurveys.map((survey) => survey.id);
+
+ const [questionCounts, responseCounts, invitationCounts, latestAnalyses, departmentResponseCounts] =
+ recentSurveyIds.length > 0
+ ? await Promise.all([
+ db.survey_questions.findAll({
+ where: { surveyId: { [Op.in]: recentSurveyIds } },
+ attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
+ group: ['surveyId'],
+ raw: true,
+ }),
+ db.survey_responses.findAll({
+ where: { surveyId: { [Op.in]: recentSurveyIds } },
+ attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
+ group: ['surveyId'],
+ raw: true,
+ }),
+ db.survey_invitations.findAll({
+ where: { surveyId: { [Op.in]: recentSurveyIds } },
+ attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
+ group: ['surveyId'],
+ raw: true,
+ }),
+ db.ai_analyses.findAll({
+ where: { surveyId: { [Op.in]: recentSurveyIds } },
+ order: [
+ ['generated_at', 'DESC'],
+ ['createdAt', 'DESC'],
+ ],
+ raw: true,
+ }),
+ db.survey_responses.findAll({
+ where: {
+ ...organizationFilter,
+ departmentId: { [Op.ne]: null },
+ },
+ attributes: ['departmentId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
+ group: ['departmentId'],
+ raw: true,
+ }),
+ ])
+ : [[], [], [], [], []];
+
+ const questionCountMap = Object.fromEntries(
+ questionCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
+ );
+ const responseCountMap = Object.fromEntries(
+ responseCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
+ );
+ const invitationCountMap = Object.fromEntries(
+ invitationCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
+ );
+
+ const latestAnalysisMap = {};
+ latestAnalyses.forEach((analysis) => {
+ if (!latestAnalysisMap[analysis.surveyId]) {
+ latestAnalysisMap[analysis.surveyId] = analysis;
+ }
+ });
+
+ const responseTrendSeed = [];
+ for (let index = 5; index >= 0; index -= 1) {
+ const date = new Date();
+ date.setUTCDate(1);
+ date.setUTCHours(0, 0, 0, 0);
+ date.setUTCMonth(date.getUTCMonth() - index);
+ responseTrendSeed.push({
+ key: monthKey(date),
+ label: monthLabel(date),
+ count: 0,
+ });
+ }
+
+ responseRows.forEach((row) => {
+ if (!row.submitted_at) {
+ return;
+ }
+ const date = new Date(row.submitted_at);
+ const bucket = responseTrendSeed.find((item) => item.key === monthKey(date));
+ if (bucket) {
+ bucket.count += 1;
+ }
+ });
+
+ const departmentBreakdown = departmentRows
+ .map((department) => {
+ const responseCount = departmentResponseCounts.find(
+ (row) => row.departmentId === department.id,
+ );
+ return {
+ id: department.id,
+ name: department.name || 'Unassigned',
+ responseCount: Number(responseCount?.count || 0),
+ };
+ })
+ .sort((left, right) => right.responseCount - left.responseCount)
+ .slice(0, 5);
+
+ const normalizedRecentSurveys = recentSurveys.map((survey) => {
+ const invitationCount = invitationCountMap[survey.id] || 0;
+ const responseCount = responseCountMap[survey.id] || 0;
+ const responseRate = invitationCount > 0 ? Math.round((responseCount / invitationCount) * 100) : 0;
+ const latestAnalysis = latestAnalysisMap[survey.id];
+
+ return {
+ id: survey.id,
+ title: survey.title,
+ description: survey.description,
+ status: survey.status,
+ visibility: survey.visibility,
+ cadence: survey.cadence,
+ opens_at: survey.opens_at,
+ closes_at: survey.closes_at,
+ createdAt: survey.createdAt,
+ templateName:
+ survey.template?.name ||
+ Object.values(TEMPLATE_LIBRARY).find((template) => template.title === survey.title)?.title ||
+ 'Custom launch',
+ questionCount: questionCountMap[survey.id] || 0,
+ responseCount,
+ invitationCount,
+ responseRate,
+ latestAnalysisAt: latestAnalysis?.generated_at || latestAnalysis?.createdAt || null,
+ latestAnalysisScore: latestAnalysis?.overall_sentiment_score
+ ? Number(latestAnalysis.overall_sentiment_score)
+ : null,
+ };
+ });
+
+ const responseRate = totalInvitations > 0 ? Math.round((totalResponses / totalInvitations) * 100) : 0;
+
+ return {
+ overview: {
+ totalSurveys,
+ activeSurveys,
+ totalResponses,
+ totalInvitations,
+ responseRate,
+ aiReadyCount,
+ activeDepartmentCount: departmentBreakdown.filter((item) => item.responseCount > 0).length,
+ },
+ templates: this.listTemplates(),
+ recentSurveys: normalizedRecentSurveys,
+ responseTrend: responseTrendSeed,
+ departmentBreakdown,
+ };
+ }
+
+ static async createSurveyFromTemplate(payload, currentUser) {
+ const template = this.resolveTemplate(payload?.templateKey);
+ if (!template) {
+ throw buildError('Please choose a valid survey template before launching.');
+ }
+
+ const title = String(payload?.title || '').trim();
+ if (!title) {
+ throw buildError('Survey title is required.');
+ }
+
+ if (payload?.opens_at && payload?.closes_at) {
+ const openDate = new Date(payload.opens_at);
+ const closeDate = new Date(payload.closes_at);
+ if (openDate > closeDate) {
+ throw buildError('Close date must be after the open date.');
+ }
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const workspace = currentUser?.organizationsId
+ ? await db.workspaces.findOne({
+ where: { organizationsId: currentUser.organizationsId },
+ order: [['createdAt', 'ASC']],
+ transaction,
+ })
+ : null;
+
+ const survey = await SurveysDBApi.create(
+ {
+ title,
+ description: String(payload?.description || template.description || '').trim(),
+ status: payload?.status || (payload?.opens_at ? 'scheduled' : 'draft'),
+ visibility: payload?.visibility || template.defaultVisibility,
+ cadence: payload?.cadence || template.defaultCadence,
+ opens_at: payload?.opens_at || null,
+ closes_at: payload?.closes_at || null,
+ include_open_text: Boolean(payload?.include_open_text ?? true),
+ allow_multiple_submissions: false,
+ template: payload?.templateId || null,
+ workspace: workspace?.id || null,
+ organizations: currentUser?.organizationsId || null,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ for (const [index, questionBlueprint] of template.questions.entries()) {
+ const question = await SurveyQuestionsDBApi.create(
+ {
+ survey: survey.id,
+ organizations: currentUser?.organizationsId || null,
+ prompt: questionBlueprint.prompt,
+ question_type: questionBlueprint.question_type,
+ order_index: index + 1,
+ is_required: Boolean(questionBlueprint.is_required),
+ min_value: questionBlueprint.min_value || null,
+ max_value: questionBlueprint.max_value || null,
+ help_text: questionBlueprint.help_text || null,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ if (Array.isArray(questionBlueprint.choices)) {
+ for (const [choiceIndex, choice] of questionBlueprint.choices.entries()) {
+ await QuestionChoicesDBApi.create(
+ {
+ question: question.id,
+ organizations: currentUser?.organizationsId || null,
+ label: choice,
+ value: choice,
+ order_index: choiceIndex + 1,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+ }
+ }
+ }
+
+ await transaction.commit();
+
+ return {
+ survey: {
+ id: survey.id,
+ title,
+ status: payload?.status || (payload?.opens_at ? 'scheduled' : 'draft'),
+ cadence: payload?.cadence || template.defaultCadence,
+ visibility: payload?.visibility || template.defaultVisibility,
+ opens_at: payload?.opens_at || null,
+ closes_at: payload?.closes_at || null,
+ },
+ template: {
+ key: template.key,
+ title: template.title,
+ },
+ questionCount: template.questions.length,
+ };
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async generateSurveyAnalysis(surveyId, currentUser) {
+ const survey = await db.surveys.findOne({
+ where: {
+ id: surveyId,
+ ...(currentUser?.organizationsId ? { organizationsId: currentUser.organizationsId } : {}),
+ },
+ include: [
+ {
+ model: db.survey_templates,
+ as: 'template',
+ },
+ ],
+ });
+
+ if (!survey) {
+ throw buildError('Survey not found.', 404);
+ }
+
+ const [questions, responses] = await Promise.all([
+ db.survey_questions.findAll({
+ where: { surveyId: survey.id },
+ order: [['order_index', 'ASC']],
+ raw: true,
+ }),
+ db.survey_responses.findAll({
+ where: { surveyId: survey.id },
+ include: [
+ {
+ model: db.departments,
+ as: 'department',
+ },
+ {
+ model: db.survey_answers,
+ as: 'survey_answers_response',
+ include: [
+ {
+ model: db.survey_questions,
+ as: 'question',
+ },
+ ],
+ },
+ ],
+ order: [['submitted_at', 'DESC']],
+ }),
+ ]);
+
+ const completedResponses = responses.filter((response) => response.is_complete);
+ if (!completedResponses.length) {
+ throw buildError('Collect at least one completed response before running AI analysis.');
+ }
+
+ const allAnswers = completedResponses.flatMap((response) =>
+ (response.survey_answers_response || []).map((answer) => ({
+ ...answer.get({ plain: true }),
+ departmentName: response.department?.name || 'Unassigned',
+ })),
+ );
+
+ const ratingAnswers = allAnswers.filter((answer) => Number.isFinite(answer.rating_value));
+ const emojiAnswers = allAnswers.filter((answer) => answer.emoji_value);
+ const openTextAnswers = allAnswers
+ .filter((answer) => answer.text_value)
+ .map((answer) => ({
+ question: answer.question?.prompt || 'Open text',
+ quote: String(answer.text_value).trim(),
+ department: answer.departmentName,
+ }))
+ .filter((answer) => answer.quote)
+ .slice(0, 18);
+
+ const ratingAverage = ratingAnswers.length
+ ? Number(
+ (
+ ratingAnswers.reduce((sum, answer) => sum + Number(answer.rating_value || 0), 0) /
+ ratingAnswers.length
+ ).toFixed(1),
+ )
+ : null;
+
+ const emojiAverage = emojiAnswers.length
+ ? Number(
+ (
+ emojiAnswers.reduce((sum, answer) => sum + (EMOJI_TO_SCORE[answer.emoji_value] || 0), 0) /
+ emojiAnswers.length
+ ).toFixed(1),
+ )
+ : null;
+
+ const choiceBreakdown = allAnswers
+ .filter((answer) => answer.choice_value)
+ .reduce((accumulator, answer) => {
+ const prompt = answer.question?.prompt || 'Choice question';
+ const match = accumulator[prompt] || {};
+ match[answer.choice_value] = (match[answer.choice_value] || 0) + 1;
+ accumulator[prompt] = match;
+ return accumulator;
+ }, {});
+
+ const departmentSignals = Object.values(
+ allAnswers.reduce((accumulator, answer) => {
+ const bucket = accumulator[answer.departmentName] || {
+ department: answer.departmentName,
+ ratings: [],
+ responses: 0,
+ };
+ if (Number.isFinite(answer.rating_value)) {
+ bucket.ratings.push(Number(answer.rating_value));
+ }
+ bucket.responses += 1;
+ accumulator[answer.departmentName] = bucket;
+ return accumulator;
+ }, {}),
+ ).map((item) => ({
+ department: item.department,
+ responseCount: item.responses,
+ averageRating: item.ratings.length
+ ? Number((item.ratings.reduce((sum, rating) => sum + rating, 0) / item.ratings.length).toFixed(1))
+ : null,
+ }));
+
+ const aiPayload = {
+ survey: {
+ id: survey.id,
+ title: survey.title,
+ description: survey.description,
+ status: survey.status,
+ cadence: survey.cadence,
+ visibility: survey.visibility,
+ template: survey.template?.name || null,
+ },
+ metrics: {
+ completedResponses: completedResponses.length,
+ ratingAverage,
+ emojiAverage,
+ },
+ questions: questions.map((question) => ({
+ prompt: question.prompt,
+ questionType: question.question_type,
+ })),
+ choiceBreakdown,
+ departmentSignals,
+ openTextAnswers,
+ };
+
+ let aiResponse;
+ try {
+ aiResponse = await LocalAIApi.createResponse(
+ {
+ input: [
+ {
+ role: 'system',
+ content:
+ 'You are an expert employee engagement analyst. Respond only with valid JSON. No markdown fences, no commentary outside JSON.',
+ },
+ {
+ role: 'user',
+ content: `Analyze the employee pulse survey data below and return JSON with this exact shape: {"overallSentimentScore": number 0-100, "executiveSummary": string, "topThemes": [{"theme": string, "confidence": "high"|"medium"|"low", "quote": string, "insight": string}], "departmentRisks": [{"department": string, "riskLevel": "low"|"medium"|"high", "riskScore": number 0-100, "signal": string}], "recommendedActions": [{"title": string, "owner": string, "timeline": string, "template": string}]}. Use real quotes only from provided openTextAnswers. Limit topThemes to 5 and recommendedActions to 3. Data: ${JSON.stringify(aiPayload)}`,
+ },
+ ],
+ },
+ { poll_interval: 5, poll_timeout: 300 },
+ );
+
+ if (!aiResponse.success) {
+ console.error('PulseSurvey AI analysis failed:', aiResponse);
+ throw buildError(aiResponse.error || aiResponse.message || 'AI analysis failed.', 502);
+ }
+
+ const parsed = LocalAIApi.decodeJsonFromResponse(aiResponse);
+ const savedAnalysis = await AiAnalysesDBApi.create(
+ {
+ workspace: survey.workspaceId || null,
+ survey: survey.id,
+ organizations: survey.organizationsId || currentUser?.organizationsId || null,
+ status: 'completed',
+ model: aiResponse?.data?.model || 'ai-proxy-default',
+ overall_sentiment_score: parsed.overallSentimentScore ?? null,
+ summary: parsed.executiveSummary || '',
+ top_themes_json: JSON.stringify(parsed.topThemes || []),
+ department_risks_json: JSON.stringify(parsed.departmentRisks || []),
+ recommended_actions_json: JSON.stringify(parsed.recommendedActions || []),
+ generated_at: new Date(),
+ },
+ {
+ currentUser,
+ },
+ );
+
+ return {
+ survey: {
+ id: survey.id,
+ title: survey.title,
+ },
+ meta: {
+ completedResponses: completedResponses.length,
+ questionCount: questions.length,
+ ratingAverage,
+ emojiAverage,
+ },
+ analysis: {
+ id: savedAnalysis.id,
+ overallSentimentScore: parsed.overallSentimentScore ?? null,
+ executiveSummary: parsed.executiveSummary || '',
+ topThemes: Array.isArray(parsed.topThemes) ? parsed.topThemes : [],
+ departmentRisks: Array.isArray(parsed.departmentRisks) ? parsed.departmentRisks : [],
+ recommendedActions: Array.isArray(parsed.recommendedActions) ? parsed.recommendedActions : [],
+ },
+ };
+ } catch (error) {
+ console.error('PulseSurvey analysis route failed:', error);
+
+ await AiAnalysesDBApi.create(
+ {
+ workspace: survey.workspaceId || null,
+ survey: survey.id,
+ organizations: survey.organizationsId || currentUser?.organizationsId || null,
+ status: 'failed',
+ model: aiResponse?.data?.model || 'ai-proxy-default',
+ summary: '',
+ error_message: error.message,
+ generated_at: new Date(),
+ },
+ {
+ currentUser,
+ },
+ );
+
+ throw error;
+ }
+ }
+
+ static parseStoredAnalysis(record) {
+ return {
+ id: record.id,
+ overallSentimentScore: record.overall_sentiment_score ? Number(record.overall_sentiment_score) : null,
+ executiveSummary: record.summary || '',
+ topThemes: parseJson(record.top_themes_json, []),
+ departmentRisks: parseJson(record.department_risks_json, []),
+ recommendedActions: parseJson(record.recommended_actions_json, []),
+ generatedAt: record.generated_at || record.createdAt || null,
+ };
+ }
+};
diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx
index 1d359e4..37da01f 100644
--- a/frontend/src/components/AsideMenuLayer.tsx
+++ b/frontend/src/components/AsideMenuLayer.tsx
@@ -3,10 +3,8 @@ 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 eb155e3..fb0fca2 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 6529710..6ee322a 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
+ {
+ href: '/pulse-hq',
+ label: 'Pulse HQ',
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ icon: 'mdiHeartPulse' in icon ? icon['mdiHeartPulse' as keyof typeof icon] : icon.mdiChartTimelineVariant ?? icon.mdiTable,
+ permissions: 'READ_SURVEYS'
+ },
{
href: '/users/users-list',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index f43d1a9..a9c9607 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,179 @@
-
-import React, { useEffect, useState } from 'react';
+import { mdiArrowRight, mdiBrain, mdiChartBoxOutline, mdiHeartPulse, mdiShieldCrownOutline } 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 BaseIcon from '../components/BaseIcon';
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 SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const landingTemplates = [
+ {
+ title: 'Weekly Pulse',
+ accent: '#8B5CF6',
+ description: 'Fast morale and workload check-ins for every team.',
+ },
+ {
+ title: 'Onboarding',
+ accent: '#14B8A6',
+ description: 'Catch new-hire friction before it turns into attrition.',
+ },
+ {
+ title: 'Quarterly Deep Dive',
+ accent: '#F97316',
+ description: 'Zoom out on strategy, growth, trust, and collaboration.',
+ },
+ {
+ title: 'Burnout Check',
+ accent: '#EF4444',
+ description: 'Surface overload signals and department-level risk quickly.',
+ },
+];
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('video');
- const [contentPosition, setContentPosition] = useState('right');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'PulseSurvey MVP'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('PulseSurvey')}
+
+
+
+
+
+
+
+
+
+
+
PulseSurvey
+
Continuous engagement intelligence
+
+
+
+
+
+
+
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
-
-
+
+
+
+
+
+ AI-powered employee engagement
+
+
+ Spot burnout risk early, launch better pulse surveys, and turn feedback into action.
+
+
+ PulseSurvey helps People Ops teams run modern engagement surveys, monitor response momentum, and generate
+ crisp AI summaries with recommended next steps — all from one polished admin workspace.
+
+
+
+
+
+
+ {[
+ ['4 launch templates', 'Weekly pulse, onboarding, quarterly deep dive, burnout checks'],
+ ['AI-ready insights', 'Sentiment, top themes, risk flags, and actions'],
+ ['Multi-tenant foundation', 'Organizations, workspaces, teams, and roles already seeded'],
+ ].map(([title, description]) => (
+
+
{title}
+
{description}
+
+ ))}
+
+
-
-
-
+
+
+
+
Inside the MVP
+
Your first complete workflow
+
+
+
+
+
+
+ {[
+ {
+ title: '1. Launch a pulse survey',
+ text: 'Choose a proven template, set cadence + visibility, and create the survey with seeded questions in one action.',
+ },
+ {
+ title: '2. Review recent survey health',
+ text: 'See response rate, invitations, question count, department participation, and quick links to view/edit details.',
+ },
+ {
+ title: '3. Generate AI analysis',
+ text: 'Turn completed responses into themes, risk signals, and recommended actions ready for leadership readouts.',
+ },
+ ].map((item) => (
+
+
{item.title}
+
{item.text}
+
+ ))}
+
+
+
+
+
+
+
+
Survey templates
+
Launch modes HR teams actually use
+
+
+ Log in to build and manage surveys →
+
+
+
+ {landingTemplates.map((template) => (
+
+
+
{template.description}
+
+ ))}
+
+
+
+
+
+
+
+
+ Public landing · private admin
+
+
Ready to try the admin experience?
+
+ Use the admin interface to launch surveys, inspect recent activity, and generate AI insights from within the secured workspace.
+
+
+
+
+
+
+
+
+
+
+
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
-
-
+ >
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return
{page} ;
};
-
diff --git a/frontend/src/pages/pulse-hq.tsx b/frontend/src/pages/pulse-hq.tsx
new file mode 100644
index 0000000..50675d7
--- /dev/null
+++ b/frontend/src/pages/pulse-hq.tsx
@@ -0,0 +1,936 @@
+import {
+ mdiArrowRight,
+ mdiBrain,
+ mdiChartLine,
+ mdiClipboardTextClockOutline,
+ mdiCreation,
+ mdiLightningBolt,
+ mdiRobotOutline,
+ mdiShieldCheck,
+ mdiTarget,
+} from '@mdi/js';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, { ReactElement, useEffect, useMemo, useState } from 'react';
+import axios from 'axios';
+import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import NotificationBar from '../components/NotificationBar';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../config';
+import { hasPermission } from '../helpers/userPermissions';
+import { useAppSelector } from '../stores/hooks';
+
+type TemplateSummary = {
+ key: string;
+ title: string;
+ accent: string;
+ description: string;
+ defaultCadence: string;
+ defaultVisibility: string;
+ questionCount: number;
+ previewQuestions: Array<{
+ prompt: string;
+ question_type: string;
+ }>;
+};
+
+type RecentSurvey = {
+ id: string;
+ title: string;
+ description: string;
+ status: string;
+ visibility: string;
+ cadence: string;
+ opens_at: string | null;
+ closes_at: string | null;
+ createdAt: string;
+ templateName: string;
+ questionCount: number;
+ responseCount: number;
+ invitationCount: number;
+ responseRate: number;
+ latestAnalysisAt: string | null;
+ latestAnalysisScore: number | null;
+};
+
+type LaunchpadSummary = {
+ overview: {
+ totalSurveys: number;
+ activeSurveys: number;
+ totalResponses: number;
+ totalInvitations: number;
+ responseRate: number;
+ aiReadyCount: number;
+ activeDepartmentCount: number;
+ };
+ templates: TemplateSummary[];
+ recentSurveys: RecentSurvey[];
+ responseTrend: Array<{
+ key: string;
+ label: string;
+ count: number;
+ }>;
+ departmentBreakdown: Array<{
+ id: string;
+ name: string;
+ responseCount: number;
+ }>;
+};
+
+type AnalysisResult = {
+ overallSentimentScore: number | null;
+ executiveSummary: string;
+ topThemes: Array<{
+ theme: string;
+ confidence: string;
+ quote: string;
+ insight: string;
+ }>;
+ departmentRisks: Array<{
+ department: string;
+ riskLevel: string;
+ riskScore: number;
+ signal: string;
+ }>;
+ recommendedActions: Array<{
+ title: string;
+ owner: string;
+ timeline: string;
+ template: string;
+ }>;
+};
+
+const cadenceLabels: Record
= {
+ one_time: 'One-time',
+ weekly: 'Weekly',
+ biweekly: 'Biweekly',
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+};
+
+const visibilityLabels: Record = {
+ anonymous: 'Anonymous',
+ identified: 'Identified',
+};
+
+const statusClassNames: Record = {
+ draft: 'bg-slate-500/15 text-slate-200',
+ scheduled: 'bg-sky-500/15 text-sky-200',
+ sending: 'bg-violet-500/15 text-violet-200',
+ active: 'bg-emerald-500/15 text-emerald-200',
+ closed: 'bg-amber-500/15 text-amber-200',
+ archived: 'bg-zinc-500/15 text-zinc-300',
+};
+
+const questionTypeLabels: Record = {
+ rating_1_10: '1–10 rating',
+ multiple_choice: 'Multiple choice',
+ open_text: 'Open text',
+ emoji_reaction: 'Emoji reaction',
+};
+
+const getDefaultTitle = (template?: TemplateSummary) => {
+ if (!template) {
+ return '';
+ }
+
+ const today = new Date().toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+
+ return `${template.title} • ${today}`;
+};
+
+const PulseHQPage = () => {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const canCreateSurveys = Boolean(currentUser && hasPermission(currentUser, 'CREATE_SURVEYS'));
+
+ const [summary, setSummary] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [pageError, setPageError] = useState('');
+ const [selectedTemplateKey, setSelectedTemplateKey] = useState('weekly_pulse');
+ const [launching, setLaunching] = useState(false);
+ const [launchError, setLaunchError] = useState('');
+ const [launchSuccess, setLaunchSuccess] = useState(null);
+ const [selectedSurveyId, setSelectedSurveyId] = useState('');
+ const [analysisLoading, setAnalysisLoading] = useState(false);
+ const [analysisError, setAnalysisError] = useState('');
+ const [analysisResult, setAnalysisResult] = useState(null);
+ const [form, setForm] = useState({
+ templateKey: 'weekly_pulse',
+ title: '',
+ description: '',
+ cadence: 'weekly',
+ visibility: 'anonymous',
+ opens_at: '',
+ closes_at: '',
+ include_open_text: true,
+ });
+
+ const selectedTemplate = useMemo(
+ () => summary?.templates.find((template) => template.key === selectedTemplateKey) || null,
+ [selectedTemplateKey, summary?.templates],
+ );
+
+ const loadSummary = async (preferredSurveyId?: string) => {
+ try {
+ setLoading(true);
+ setPageError('');
+ const response = await axios.get('/surveys/launchpad/summary');
+ const nextSummary = response.data as LaunchpadSummary;
+ setSummary(nextSummary);
+ setSelectedSurveyId((current) => {
+ if (preferredSurveyId) {
+ return preferredSurveyId;
+ }
+ if (current && nextSummary.recentSurveys.some((survey) => survey.id === current)) {
+ return current;
+ }
+ return nextSummary.recentSurveys[0]?.id || '';
+ });
+ setSelectedTemplateKey((current) => {
+ if (nextSummary.templates.some((template) => template.key === current)) {
+ return current;
+ }
+ return nextSummary.templates[0]?.key || 'weekly_pulse';
+ });
+ setForm((current) => {
+ const template =
+ nextSummary.templates.find((item) => item.key === current.templateKey) || nextSummary.templates[0];
+
+ return {
+ ...current,
+ templateKey: template?.key || current.templateKey,
+ cadence: current.cadence || template?.defaultCadence || 'weekly',
+ visibility: current.visibility || template?.defaultVisibility || 'anonymous',
+ description: current.description || template?.description || '',
+ title: current.title || getDefaultTitle(template),
+ };
+ });
+ } catch (error: any) {
+ console.error('Failed to load Pulse HQ summary:', error);
+ setPageError(error?.response?.data || error?.message || 'Failed to load Pulse HQ.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadSummary();
+ }, []);
+
+ useEffect(() => {
+ if (!selectedTemplate) {
+ return;
+ }
+
+ setForm((current) => ({
+ ...current,
+ templateKey: selectedTemplate.key,
+ description:
+ current.description && current.templateKey === selectedTemplate.key
+ ? current.description
+ : selectedTemplate.description,
+ cadence: selectedTemplate.defaultCadence,
+ visibility: selectedTemplate.defaultVisibility,
+ title: current.title ? current.title : getDefaultTitle(selectedTemplate),
+ }));
+ }, [selectedTemplateKey, selectedTemplate?.key]);
+
+ const trendMax = useMemo(() => {
+ const counts = summary?.responseTrend.map((item) => item.count) || [];
+ return Math.max(...counts, 1);
+ }, [summary?.responseTrend]);
+
+ const selectedSurvey = useMemo(
+ () => summary?.recentSurveys.find((survey) => survey.id === selectedSurveyId) || null,
+ [selectedSurveyId, summary?.recentSurveys],
+ );
+
+ const handleFieldChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const { name, value, type } = event.target;
+ const nextValue = type === 'checkbox' ? (event.target as HTMLInputElement).checked : value;
+ setForm((current) => ({
+ ...current,
+ [name]: nextValue,
+ }));
+ };
+
+ const handleTemplateSelect = (templateKey: string) => {
+ setSelectedTemplateKey(templateKey);
+ const template = summary?.templates.find((item) => item.key === templateKey);
+ if (!template) {
+ return;
+ }
+
+ setForm((current) => ({
+ ...current,
+ templateKey,
+ cadence: template.defaultCadence,
+ visibility: template.defaultVisibility,
+ description: template.description,
+ title: getDefaultTitle(template),
+ }));
+ };
+
+ const handleLaunch = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setLaunchError('');
+ setLaunchSuccess(null);
+ setAnalysisError('');
+
+ if (!form.title.trim()) {
+ setLaunchError('Give this launch a clear title so your team recognizes it in the dashboard.');
+ return;
+ }
+
+ if (form.opens_at && form.closes_at && new Date(form.opens_at) > new Date(form.closes_at)) {
+ setLaunchError('Choose a close date that comes after the open date.');
+ return;
+ }
+
+ try {
+ setLaunching(true);
+ const response = await axios.post('/surveys/launchpad/create', form);
+ const payload = response.data;
+ setLaunchSuccess({
+ surveyId: payload.survey.id,
+ title: payload.survey.title,
+ questionCount: payload.questionCount,
+ templateTitle: payload.template.title,
+ });
+ setAnalysisResult(null);
+ await loadSummary(payload.survey.id);
+ } catch (error: any) {
+ console.error('Failed to launch survey:', error);
+ setLaunchError(error?.response?.data || error?.message || 'Survey launch failed.');
+ } finally {
+ setLaunching(false);
+ }
+ };
+
+ const handleGenerateAnalysis = async (surveyId: string) => {
+ if (!surveyId) {
+ setAnalysisError('Pick a survey with responses before generating AI analysis.');
+ return;
+ }
+
+ try {
+ setAnalysisLoading(true);
+ setAnalysisError('');
+ const response = await axios.post(`/surveys/${surveyId}/pulse-analysis`);
+ setAnalysisResult(response.data.analysis);
+ await loadSummary(surveyId);
+ } catch (error: any) {
+ console.error('Failed to generate AI analysis:', error);
+ setAnalysisError(
+ error?.response?.data || error?.message || 'AI analysis could not be generated right now.',
+ );
+ } finally {
+ setAnalysisLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Pulse HQ')}
+
+
+
+
+
+
+ {pageError && (
+
+ {pageError}
+
+ )}
+
+ {launchSuccess && (
+
+
+
+
+ }
+ >
+ {`${launchSuccess.title} is ready. ${launchSuccess.questionCount} questions were created from ${launchSuccess.templateTitle}.`}
+
+ )}
+
+
+
+
+
+
+
+
+ PulseSurvey launchpad
+
+
+
+ Launch recurring employee pulse surveys in minutes — then turn responses into AI-backed action.
+
+
+ This first slice gives your People team one polished workspace to launch a survey, review recent sends,
+ and generate an executive-ready AI analysis without leaving the app.
+
+
+
+ {[
+ {
+ label: 'Survey volume',
+ value: summary?.overview.totalSurveys ?? '—',
+ note: 'Total launches tracked',
+ },
+ {
+ label: 'Live response rate',
+ value: summary ? `${summary.overview.responseRate}%` : '—',
+ note: 'Across all invitations',
+ },
+ {
+ label: 'AI-ready surveys',
+ value: summary?.overview.aiReadyCount ?? '—',
+ note: 'Completed responses collected',
+ },
+ ].map((metric) => (
+
+
{metric.label}
+
{metric.value}
+
{metric.note}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
Template lineup
+
Choose your first pulse
+
+
+
+
+
+
+ {summary?.templates.map((template) => {
+ const active = selectedTemplateKey === template.key;
+ return (
+
handleTemplateSelect(template.key)}
+ className={`rounded-2xl border px-4 py-4 text-left transition duration-150 ${
+ active
+ ? 'border-white/30 bg-white/10 shadow-lg'
+ : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/5'
+ }`}
+ >
+
+
+
+
{template.description}
+
+
+ {template.questionCount} Qs
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
Workflow preview
+
This launch creates
+
+
+
+
+ {selectedTemplate?.previewQuestions.map((question, index) => (
+
+
+ {`Q${index + 1}`}
+
+ {questionTypeLabels[question.question_type] || question.question_type}
+
+
+
{question.prompt}
+
+ ))}
+
+
+ After launch, your survey appears below, inherits cadence + visibility defaults, and becomes eligible for AI
+ analysis as soon as completed responses start coming in.
+
+
+
+
+
+
+
+
+
Launch survey
+
Create a branded pulse from a proven template
+
+
+
+
+ {!canCreateSurveys && (
+
+ You have read access to Pulse HQ, but launching surveys requires the CREATE_SURVEYS permission.
+
+ )}
+
+ {launchError && (
+
+ {launchError}
+
+ )}
+
+
+
+
+
+
+
+
+
Response momentum
+
Last 6 months
+
+
+
+
+ {(summary?.responseTrend || []).map((point) => (
+
+
{point.count}
+
+
{point.label}
+
+ ))}
+
+
+
+
+
+
+
Department signal
+
Who is responding
+
+
+ {summary?.overview.activeDepartmentCount ?? 0} active departments
+
+
+
+ {(summary?.departmentBreakdown || []).length === 0 && (
+
+ Department response data will appear here once invitations start turning into submissions.
+
+ )}
+ {(summary?.departmentBreakdown || []).map((department) => {
+ const maxDepartmentResponses = Math.max(
+ ...(summary?.departmentBreakdown || []).map((item) => item.responseCount),
+ 1,
+ );
+ return (
+
+
+ {department.name}
+ {department.responseCount} responses
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
Recent launches
+
Survey list + detail shortcuts
+
+
+
+
+ {loading && (
+
+ Loading recent survey activity…
+
+ )}
+
+ {!loading && (summary?.recentSurveys || []).length === 0 && (
+
+ No surveys yet. Launch your first pulse above and it will appear here instantly.
+
+ )}
+
+
+ {(summary?.recentSurveys || []).map((survey) => (
+
+
+
+
+
{survey.title}
+
+ {survey.status}
+
+
+
+ {survey.description || `${survey.templateName} survey ready for audience and invitation setup.`}
+
+
+
+ setSelectedSurveyId(survey.id)}
+ className='h-3.5 w-3.5 border-white/20 bg-slate-900 text-violet-400 focus:ring-violet-400'
+ />
+ Focus for AI
+
+
+
+
+ {[
+ ['Questions', survey.questionCount],
+ ['Responses', survey.responseCount],
+ ['Invitations', survey.invitationCount],
+ ['Response rate', `${survey.responseRate}%`],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+
+ {cadenceLabels[survey.cadence] || survey.cadence}
+ {visibilityLabels[survey.visibility] || survey.visibility}
+
+ {survey.opens_at
+ ? `Opens ${new Date(survey.opens_at).toLocaleDateString()}`
+ : `Created ${new Date(survey.createdAt).toLocaleDateString()}`}
+
+
+ {survey.latestAnalysisAt
+ ? `Last AI run ${new Date(survey.latestAnalysisAt).toLocaleDateString()}`
+ : 'No AI analysis yet'}
+
+
+
+
+ View
+
+
+ Edit
+
+
+
+
+ ))}
+
+
+
+
+
+
+
AI insights
+
Generate executive-ready analysis
+
+
+
+
+ Use the currently selected survey to synthesize sentiment, themes, burnout risk, and recommended actions.
+
+
+
+
+ Survey focus
+ setSelectedSurveyId(event.target.value)}
+ >
+ {(summary?.recentSurveys || []).map((survey) => (
+
+ {survey.title}
+
+ ))}
+
+
+
+
+ {selectedSurvey
+ ? `${selectedSurvey.responseCount} responses across ${selectedSurvey.questionCount} questions.`
+ : 'Pick a survey to begin.'}
+
+
handleGenerateAnalysis(selectedSurveyId)}
+ disabled={analysisLoading || !selectedSurveyId}
+ />
+
+
+
+ {analysisError && (
+
+ {analysisError}
+
+ )}
+
+ {!analysisResult && !analysisLoading && !analysisError && (
+
+ Your AI summary will appear here after you run the analysis.
+
+ )}
+
+ {analysisLoading && (
+
+ Reviewing survey answers, clustering themes, and drafting next-step recommendations…
+
+ )}
+
+ {analysisResult && (
+
+
+
+
+
Overall sentiment
+
+ {analysisResult.overallSentimentScore ?? '—'}
+ / 100
+
+
+
+ {analysisResult.executiveSummary}
+
+
+
+
+
+
Top themes
+ {analysisResult.topThemes.map((theme, index) => (
+
+
+
{theme.theme}
+
+ {theme.confidence}
+
+
+
{theme.insight}
+ {theme.quote &&
“{theme.quote}” }
+
+ ))}
+
+
+
+
+
Department risk
+ {analysisResult.departmentRisks.map((risk, index) => (
+
+
+
{risk.department}
+
+ {risk.riskLevel} · {risk.riskScore}
+
+
+
{risk.signal}
+
+ ))}
+
+
+
Recommended actions
+ {analysisResult.recommendedActions.map((action, index) => (
+
+
{action.title}
+
{action.template}
+
+ Owner: {action.owner}
+ Timeline: {action.timeline}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ >
+ );
+};
+
+PulseHQPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default PulseHQPage;
diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx
index 00f5168..005eb07 100644
--- a/frontend/src/pages/search.tsx
+++ b/frontend/src/pages/search.tsx
@@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
-import { useAppDispatch } from '../stores/hooks';
-
-import { useAppSelector } from '../stores/hooks';
+import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';