diff --git a/.eslintrc.json b/.eslintrc.json index cb608c4..d5f78ec 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -35,7 +35,8 @@ }, { "files": [ - "src/app/components/factorSlider/FactorSlider.tsx" + "src/app/components/factorSlider/FactorSlider.tsx", + "src/app/context/AssessmentContext.tsx" ], "rules": { "react-refresh/only-export-components": "off" diff --git a/public/screenshot_assessment.png b/public/screenshot_assessment.png index 88297c4..0502fc9 100644 Binary files a/public/screenshot_assessment.png and b/public/screenshot_assessment.png differ diff --git a/public/screenshot_calc.png b/public/screenshot_calc.png index 4c44cba..78307b5 100644 Binary files a/public/screenshot_calc.png and b/public/screenshot_calc.png differ diff --git a/src/app/app.tsx b/src/app/app.tsx index 295c856..9dd7fdd 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -18,15 +18,18 @@ import { Route, Routes } from 'react-router-dom'; import Home from './pages/home'; +import { AssessmentProvider } from './context/AssessmentContext'; export function App() { return ( - - - + + + + + ); } diff --git a/src/app/components/calculator/Calculator.tsx b/src/app/components/calculator/Calculator.tsx index 115c6eb..33dc0c5 100644 --- a/src/app/components/calculator/Calculator.tsx +++ b/src/app/components/calculator/Calculator.tsx @@ -10,8 +10,7 @@ export interface CalculatorProps { } function Calculator(props: CalculatorProps) { - - const [factors, setFactors] = useState(initialFactors || []); // Add fallback + const [factors, setFactors] = useState(() => (initialFactors || []).map(f => ({ ...f }))); const handleScoreChange = (index: number, value: number) => { const newFactors = [...factors]; @@ -27,6 +26,10 @@ function Calculator(props: CalculatorProps) { } }; + const handleAssessmentReset = () => { + setFactors((initialFactors || []).map(f => ({ ...f }))); + }; + const calculateCompositeScore = () => { return (factors || []).reduce((acc, factor) => acc + factor.score * factor.weight, 0); }; @@ -73,6 +76,7 @@ function Calculator(props: CalculatorProps) { {/* Maturity Level Indicator */} diff --git a/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.spec.tsx b/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.spec.tsx index d73f137..3e0c7c2 100644 --- a/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.spec.tsx +++ b/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render } from '../../../test-utils'; import CompositeScoreDisplay from './CompositeScoreDisplay'; diff --git a/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.tsx b/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.tsx index c28ebb8..ecc3ba7 100644 --- a/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.tsx +++ b/src/app/components/compositeScoreDisplay/CompositeScoreDisplay.tsx @@ -2,79 +2,41 @@ import React, { useState } from 'react'; import { Button } from 'primereact/button'; import styles from './CompositeScoreDisplay.module.scss'; import QuestionnaireDialog from '../questionnaire/QuestionnaireDialog'; - -interface AssessmentResult { - sectionName: string; - score: number; - maxScore: number; - answers: { questionId: string; questionText: string; selectedAnswer: string; pointsEarned: number; maxPoints: number }[]; - completedAt: Date; -} +import { useAssessmentContext } from '../../context/AssessmentContext'; export interface CompositeScoreDisplayProps { score: number; onQuestionnaireScoreUpdate?: (sectionIndex: number, score: number) => void; + /** + * Optional callback invoked after the guided assessment has been reset + * and the assessment dialog has been closed. Use this to perform any + * additional cleanup or UI updates when starting a new assessment. + */ + onAssessmentReset?: () => void; + 'data-testid'?: string; } -const CompositeScoreDisplay: React.FC = ({ score, onQuestionnaireScoreUpdate }) => { +const CompositeScoreDisplay: React.FC = ({ + score, + onQuestionnaireScoreUpdate, + onAssessmentReset, + 'data-testid': testId, +}) => { const [open, setOpen] = useState(false); - const [assessmentResults, setAssessmentResults] = useState([]); - const [isAssessmentComplete, setIsAssessmentComplete] = useState(false); - - const handleAssessmentComplete = (results: AssessmentResult[]) => { - setAssessmentResults(results); - setIsAssessmentComplete(true); - }; - - const generateQuickReport = () => { - if (!isAssessmentComplete) return; - - const totalScore = assessmentResults.reduce((sum, result) => sum + result.score, 0); - const maxTotalScore = assessmentResults.length * 25; - const overallPercentage = (totalScore / maxTotalScore) * 100; - - let maturityLevel = 'Developing'; - if (overallPercentage >= 80) maturityLevel = 'Expert'; - else if (overallPercentage >= 65) maturityLevel = 'Advanced'; - else if (overallPercentage >= 45) maturityLevel = 'Intermediate'; - else if (overallPercentage >= 25) maturityLevel = 'Basic'; - - const quickReport = `# PolydraIQ™ Assessment Summary - -**Overall Score:** ${totalScore.toFixed(1)} / ${maxTotalScore} (${overallPercentage.toFixed(1)}%) -**Maturity Level:** ${maturityLevel} -**Completed:** ${new Date().toLocaleDateString()} - -${assessmentResults.map(result => ` -**${result.sectionName}:** ${result.score.toFixed(1)}/25 (${((result.score / 25) * 100).toFixed(1)}%)`).join('')} - -*Complete assessment details available in the Guided Assessment dialog.*`; + const [dialogKey, setDialogKey] = useState(0); + const { isAssessmentComplete, printReport, resetAssessment } = useAssessmentContext(); - const printWindow = window.open('', '_blank'); - if (printWindow) { - printWindow.document.write(` - - - PolydraIQ Assessment Summary - - - -
${quickReport.replace(/\\*/g, '').replace(/#/g, '')}
- - - `); - printWindow.document.close(); - printWindow.print(); + const handleResetAssessment = () => { + resetAssessment(); + setDialogKey((k) => k + 1); + setOpen(false); + if (onAssessmentReset) { + onAssessmentReset(); } }; return ( -
+

Composite Quality Score: {score.toFixed(2)}

{isAssessmentComplete && ( -
- ✅ Assessment Completed - Detailed report available in Guided Assessment +
+
+ ✅ Assessment Completed - Detailed report available in Guided Assessment +
+
)} setOpen(false)} onScoreUpdate={onQuestionnaireScoreUpdate} - onAssessmentComplete={handleAssessmentComplete} />
); diff --git a/src/app/components/questionnaire/QuestionnaireDialog.tsx b/src/app/components/questionnaire/QuestionnaireDialog.tsx index 536cc41..bc179f6 100644 --- a/src/app/components/questionnaire/QuestionnaireDialog.tsx +++ b/src/app/components/questionnaire/QuestionnaireDialog.tsx @@ -5,15 +5,8 @@ import { RadioButton } from 'primereact/radiobutton'; import { ProgressBar } from 'primereact/progressbar'; import { Card } from 'primereact/card'; import { Badge } from 'primereact/badge'; - -// Assessment result interface for comprehensive tracking -interface AssessmentResult { - sectionName: string; - score: number; - maxScore: number; - answers: { questionId: string; questionText: string; selectedAnswer: string; pointsEarned: number; maxPoints: number }[]; - completedAt: Date; -} +import { AssessmentResult, getAssessmentSummary, printAssessmentReport } from './assessmentPrint'; +import { useAssessmentContext } from '../../context/AssessmentContext'; const SECTIONS = [ 'Governance & Accountability', @@ -436,16 +429,14 @@ export interface QuestionnaireDialogProps { open: boolean; onClose: () => void; onScoreUpdate?: (sectionIndex: number, score: number) => void; - onAssessmentComplete?: (results: AssessmentResult[]) => void; } -const QuestionnaireDialog: React.FC = ({ open, onClose, onScoreUpdate, onAssessmentComplete }) => { +const QuestionnaireDialog: React.FC = ({ open, onClose, onScoreUpdate }) => { const [tab, setTab] = useState(0); const [answers, setAnswers] = useState<{[key: string]: number}>({}); const [selectedAnswerTexts, setSelectedAnswerTexts] = useState<{[key: string]: string}>({}); const [sectionScores, setSectionScores] = useState(Array(6).fill(0)); - const [assessmentResults, setAssessmentResults] = useState([]); - const [isAssessmentComplete, setIsAssessmentComplete] = useState(false); + const { assessmentResults, setAssessmentResults, isAssessmentComplete, setAssessmentComplete } = useAssessmentContext(); const currentQuestions = QUESTION_SETS[SECTIONS[tab]] || []; const maxPossibleScore = currentQuestions.reduce((sum, q) => sum + q.points, 0); @@ -454,6 +445,15 @@ const QuestionnaireDialog: React.FC = ({ open, onClose return sum + (answer !== undefined ? answer : 0); }, 0); + const answeredCount = currentQuestions.filter(q => answers[q.id] !== undefined).length; + const completionPercent = currentQuestions.length + ? Math.round((answeredCount / currentQuestions.length) * 100) + : 0; + + const normalizedSectionScore = maxPossibleScore + ? (currentScore / maxPossibleScore) * 25 + : 0; + const handleAnswerChange = (questionId: string, score: number, answerText: string) => { setAnswers(prev => ({ ...prev, [questionId]: score })); setSelectedAnswerTexts(prev => ({ ...prev, [questionId]: answerText })); @@ -498,10 +498,7 @@ const QuestionnaireDialog: React.FC = ({ open, onClose // Check if this is the last section if (tab === SECTIONS.length - 1) { - setIsAssessmentComplete(true); - if (onAssessmentComplete) { - onAssessmentComplete(updatedResults); - } + setAssessmentComplete(true); } else { // Move to next tab setTab(tab + 1); @@ -527,27 +524,18 @@ const QuestionnaireDialog: React.FC = ({ open, onClose default: return '#6b7280'; } }; - const generateReport = () => { - const totalScore = assessmentResults.reduce((sum, result) => sum + result.score, 0); - const maxTotalScore = assessmentResults.length * 25; - const overallPercentage = (totalScore / maxTotalScore) * 100; - - let maturityLevel = 'Developing'; - if (overallPercentage >= 80) maturityLevel = 'Expert'; - else if (overallPercentage >= 65) maturityLevel = 'Advanced'; - else if (overallPercentage >= 45) maturityLevel = 'Intermediate'; - else if (overallPercentage >= 25) maturityLevel = 'Basic'; - + const { totalScore, maxTotalScore, overallPercentage, maturityLevel } = getAssessmentSummary(assessmentResults); + const reportContent = ` # PolydraIQ™ AI Governance Assessment Report -Generated: ${new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' +Generated: ${new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', })} ## Executive Summary @@ -557,15 +545,23 @@ Generated: ${new Date().toLocaleDateString('en-US', { ## Section Breakdown -${assessmentResults.map(result => ` +${assessmentResults + .map( + (result) => ` ### ${result.sectionName} **Score:** ${result.score.toFixed(1)} / 25 (${((result.score / 25) * 100).toFixed(1)}%) -${result.answers.map(answer => ` +${result.answers + .map( + (answer) => ` **Q:** ${answer.questionText} **A:** ${answer.selectedAnswer} (${answer.pointsEarned}/${answer.maxPoints} pts) -`).join('')} -`).join('')} +`, + ) + .join('')} +`, + ) + .join('')} ## Recommendations @@ -575,10 +571,10 @@ Based on your assessment results, focus on areas with lower scores to improve yo *This report was generated using PolydraIQ™ Assessment Platform* *For professional AI governance consulting: https://www.inference-stack.com/* `; - + return reportContent; }; - + const downloadReport = () => { const reportContent = generateReport(); const blob = new Blob([reportContent], { type: 'text/markdown' }); @@ -591,52 +587,36 @@ Based on your assessment results, focus on areas with lower scores to improve yo document.body.removeChild(a); URL.revokeObjectURL(url); }; - - const printReport = () => { - const reportContent = generateReport(); - const printWindow = window.open('', '_blank'); - if (printWindow) { - printWindow.document.write(` - - - PolydraIQ Assessment Report - - - -
${reportContent.replace(/\*/g, '').replace(/#/g, '')}
- - - `); - printWindow.document.close(); - printWindow.print(); + + const handlePrintReport = () => { + if (!assessmentResults.length) { + window.alert('Please complete the guided assessment before printing a report.'); + return; } + + printAssessmentReport(assessmentResults); }; return ( -
{/* Tab Navigation */} -
+

Assessment Facets

{SECTIONS.map((section, idx) => (
setTab(idx)} > -
+
{section}
{sectionScores[idx] > 0 && ( - )}
@@ -670,16 +665,21 @@ Based on your assessment results, focus on areas with lower scores to improve yo

{SECTIONS[tab]}

-
- + - {currentScore.toFixed(1)} / {maxPossibleScore} points + {answeredCount} / {currentQuestions.length || 0} answered
+
+ Section progress reflects how many questions you've answered. Your answers currently sum to + {' '} + {currentScore.toFixed(1)} / {maxPossibleScore} points. +

Answer questions to assess this facet's maturity. Higher complexity questions contribute more points.

@@ -688,162 +688,128 @@ Based on your assessment results, focus on areas with lower scores to improve yo {/* Questions */}
{currentQuestions.map((question, idx) => ( - -
-
- {getCategoryIcon(question.category)} {question.category.toUpperCase()} ({question.points}pt) +
+
+ {getCategoryIcon(question.category)} Question {idx + 1}
-
-
- {idx + 1}. {question.text} -
-
- {question.options.map((option, optIdx) => ( -
handleAnswerChange(question.id, option.score, option.text)} - > - { - const selectedOption = question.options.find(opt => opt.score === e.value); - handleAnswerChange(question.id, e.value, selectedOption?.text || ''); - }} - checked={answers[question.id] === option.score} - style={{ marginRight: '8px' }} - /> - {option.text} - -
- ))} + + Weight: {question.points} pts · Level: {question.category} + +
+ +
{question.text}
+ +
+ {question.options.map((option, optionIdx) => ( +
handleAnswerChange(question.id, option.score, option.text)} + > + handleAnswerChange(question.id, option.score, option.text)} + checked={answers[question.id] === option.score} + /> +
-
+ ))}
))}
- {/* Calculate Score Button and Report Generation */} -
-
-
- Calculated Score: {calculateSectionScore().toFixed(2)} / 25 + {/* Actions */} +
+
+ Current section score (normalized):{' '} + {normalizedSectionScore.toFixed(1)} / 25 +
+
+
+
-
- {tab > 0 && ( -
- - {/* Report Generation Section - Only show when assessment is complete */} - {isAssessmentComplete && ( -
-
-
-
- ✅ Assessment Complete! -
-
- Total Score: {assessmentResults.reduce((sum, result) => sum + result.score, 0).toFixed(1)} / 150 -
-
-
-
-
-
- )}
diff --git a/src/app/components/questionnaire/assessmentPrint.ts b/src/app/components/questionnaire/assessmentPrint.ts new file mode 100644 index 0000000..e70efa8 --- /dev/null +++ b/src/app/components/questionnaire/assessmentPrint.ts @@ -0,0 +1,266 @@ +export interface AssessmentAnswerDetail { + questionId: string; + questionText: string; + selectedAnswer: string; + pointsEarned: number; + maxPoints: number; +} + +export interface AssessmentResult { + sectionName: string; + score: number; + maxScore: number; + answers: AssessmentAnswerDetail[]; + completedAt: Date; +} + +export interface AssessmentSummary { + totalScore: number; + maxTotalScore: number; + overallPercentage: number; + maturityLevel: string; +} + +export const getAssessmentSummary = (assessmentResults: AssessmentResult[]): AssessmentSummary => { + if (!assessmentResults.length) { + return { + totalScore: 0, + maxTotalScore: 0, + overallPercentage: 0, + maturityLevel: 'Developing', + }; + } + + const totalScore = assessmentResults.reduce((sum, result) => sum + result.score, 0); + const maxTotalScore = assessmentResults.reduce((sum, result) => sum + result.maxScore, 0); + const overallPercentage = maxTotalScore > 0 ? (totalScore / maxTotalScore) * 100 : 0; + + let maturityLevel = 'Developing'; + if (overallPercentage >= 80) maturityLevel = 'Expert'; + else if (overallPercentage >= 65) maturityLevel = 'Advanced'; + else if (overallPercentage >= 45) maturityLevel = 'Intermediate'; + else if (overallPercentage >= 25) maturityLevel = 'Basic'; + + return { totalScore, maxTotalScore, overallPercentage, maturityLevel }; +}; + +export const printAssessmentReport = (assessmentResults: AssessmentResult[]): void => { + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + const { totalScore, maxTotalScore, overallPercentage, maturityLevel } = getAssessmentSummary(assessmentResults); + + const maturityColorMap: Record = { + Expert: '#ef4444', + Advanced: '#f97316', + Intermediate: '#eab308', + Basic: '#22c55e', + Developing: '#6b7280', + }; + + const maturityColor = maturityColorMap[maturityLevel] ?? '#3b82f6'; + + const sectionsHtml = assessmentResults + .map((result) => { + const sectionPercentage = result.maxScore > 0 ? (result.score / result.maxScore) * 100 : 0; + return ` +
+

${result.sectionName}

+
+ Score: ${result.score.toFixed(1)} / 25 + ${sectionPercentage.toFixed(1)}% +
+
+ ${result.answers + .map( + (answer) => ` +
+
Q: ${answer.questionText}
+
A: ${answer.selectedAnswer}
+
${answer.pointsEarned.toFixed(1)} / ${answer.maxPoints.toFixed(1)} pts
+
+ `, + ) + .join('')} +
+
+ `; + }) + .join(''); + + printWindow.document.write(` + + + PolydraIQ Assessment Report + + + +
+

PolydraIQ AI Governance Assessment Report

+
Generated: ${new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })}
+
+ +
+
+
Overall Score: ${totalScore.toFixed(1)} / ${maxTotalScore}
+
Overall Maturity: ${overallPercentage.toFixed(1)}%
+
+
Maturity Level: ${maturityLevel}
+
+ +
+ Section scores are normalized to a maximum of 25 points each. Detailed responses are listed below for audit and follow-up planning. +
+ + ${sectionsHtml} + +
+ USE DISCLAIMER: This assessment report is generated from a self-assessed questionnaire and is provided "AS IS" for informational and preliminary evaluation purposes only. It does not constitute legal, regulatory, risk, or audit advice, and does not create any form of certification or accreditation. Organizations remain solely responsible for their own governance, risk, and compliance decisions and should seek qualified professional guidance for any production or regulatory use. +
+ + + `); + + printWindow.document.close(); + printWindow.print(); +} diff --git a/src/app/context/AssessmentContext.tsx b/src/app/context/AssessmentContext.tsx new file mode 100644 index 0000000..1b22461 --- /dev/null +++ b/src/app/context/AssessmentContext.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { AssessmentResult, printAssessmentReport } from '../components/questionnaire/assessmentPrint'; + +interface AssessmentContextValue { + assessmentResults: AssessmentResult[]; + /** Replace the entire result set (e.g., after completing or recomputing the assessment). */ + setAssessmentResults: (results: AssessmentResult[]) => void; + /** Whether the guided assessment has been fully completed. */ + isAssessmentComplete: boolean; + /** Mark the assessment as complete or incomplete. */ + setAssessmentComplete: (complete: boolean) => void; + /** Clear results and completion state (for a fresh run). */ + resetAssessment: () => void; + /** Invoke the shared print helper for the current results. */ + printReport: () => void; +} + +const AssessmentContext = createContext(undefined); + +export const AssessmentProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [assessmentResults, setAssessmentResultsState] = useState([]); + const [isAssessmentComplete, setAssessmentComplete] = useState(false); + + const resetAssessment = () => { + setAssessmentResultsState([]); + setAssessmentComplete(false); + }; + + const printReport = () => { + if (!assessmentResults.length) { + window.alert('To generate a full report, please complete the Guided Assessment first.'); + return; + } + printAssessmentReport(assessmentResults); + }; + + return ( + + {children} + + ); +}; + +export const useAssessmentContext = (): AssessmentContextValue => { + const ctx = useContext(AssessmentContext); + if (!ctx) { + throw new Error('useAssessmentContext must be used within an AssessmentProvider'); + } + return ctx; +}; diff --git a/src/app/pages/home.spec.tsx b/src/app/pages/home.spec.tsx index d72bc5d..f97ad69 100644 --- a/src/app/pages/home.spec.tsx +++ b/src/app/pages/home.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render } from '../../test-utils'; import Home from './home'; diff --git a/src/test-utils/index.tsx b/src/test-utils/index.tsx index 3fb9d43..13174f1 100644 --- a/src/test-utils/index.tsx +++ b/src/test-utils/index.tsx @@ -5,6 +5,7 @@ import { render, RenderOptions, RenderResult } from '@testing-library/react'; import React, { ReactElement } from 'react'; import { BrowserRouter } from 'react-router-dom'; +import { AssessmentProvider } from '../app/context/AssessmentContext'; // Mock localStorage export const mockLocalStorage = (() => { @@ -70,10 +71,13 @@ export const customRender = ( { withRouter = false, ...options }: CustomRenderOptions = {} ): RenderResult => { const AllTheProviders = ({ children }: { children: React.ReactNode }) => { + let content = children; + if (withRouter) { - return {children}; + content = {content}; } - return <>{children}; + + return {content}; }; return render(ui, { wrapper: AllTheProviders, ...options });