// Assessment funnel — 212 items (187 Likert + 25 AI preference).
// REQUIRES: engine/itembank.jsx loaded before this file (sets window.ALL_ITEMS).
// Place this file at: export/src/screens/Assessment.jsx (replaces existing file)
const LIKERT_LABELS = [
'Very Inaccurate',
'Moderately Inaccurate',
'Neither',
'Moderately Accurate',
'Very Accurate',
];
// [EDIT 2026-05-11] Onboarding steps moved here from OnboardingScreen — now a pre-flight phase
const ONBOARDING_STEPS = [
{ title: "Welcome, let's set expectations", body: "This takes about 25 minutes. Answer honestly — the engine runs quality checks for central tendency and acquiescence. There are no right answers.", icon: "◌" },
{ title: "What you'll get", body: "A personal profile across 10 dimensions, a blind-spot report tailored to your score pattern, and a personal AI constitution you can load into Claude, ChatGPT, or Gemini.", icon: "◎" },
{ title: "Consent & data", body: "Your psychometric data is encrypted at rest. We never surface admin-only (SD3) scores. You can delete your profile at any time.", icon: "▽" },
];
function AssessmentScreen({ onEnter }) {
const isMobile = window.useIsMobile ? window.useIsMobile() : false;
// [EDIT 2026-05-11] Pre-flight onboarding phase — 'onboarding' | 'questions'
const [phase, setPhase] = React.useState('onboarding');
const [step, setStep] = React.useState(0);
const [consents, setConsents] = React.useState({ data: false, pair: false, ai: false });
const [submitting, setSubmitting] = React.useState(false);
const [alreadyCompleted, setAlreadyCompleted] = React.useState(false);
React.useEffect(() => {
try {
const user = JSON.parse(localStorage.getItem('kalibr.user') || '{}');
if (user.id) {
const cards = JSON.parse(localStorage.getItem('kalibr.be.cards') || '{}');
if (cards[user.id]) { setAlreadyCompleted(true); return; }
}
} catch (e) {}
if (window.kalibrAuth) {
fetch('/me/scored-card', { headers: window.kalibrAuth.getHeaders() })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && d.completed) setAlreadyCompleted(true); })
.catch(() => {});
}
}, []);
// All hooks must be declared before any conditional return
const allItems = React.useMemo(() => {
const src = window.ALL_ITEMS;
if (!src || !src.length) {
console.warn('[Kalibr] window.ALL_ITEMS not found — is engine/itembank.jsx loaded?');
return [];
}
return src.map(it => {
if (it.type === 'likert') {
return { id: it.id, text: it.text, kind: 'likert' };
}
return {
id: it.id,
text: it.stem,
kind: 'mc',
options: it.options.map(o => o[1]),
letters: it.options.map(o => o[0]),
};
});
}, []);
React.useEffect(() => {
// ChatGPT 5/13 — Non-blocking shared-manifest check keeps frontend item codes aligned with backend validation.
if (!allItems.length) return;
fetch('/assessment_manifest.json')
.then(r => r.ok ? r.json() : null)
.then(manifest => {
if (!manifest || !Array.isArray(manifest.requiredIds)) return;
const ids = allItems.map(it => it.id);
const sameLength = ids.length === manifest.expectedResponseCount && ids.length === manifest.requiredIds.length;
const sameOrder = sameLength && ids.every((id, idx) => id === manifest.requiredIds[idx]);
if (!sameOrder) console.warn('[Kalibr] Assessment manifest mismatch', { frontendCount: ids.length, manifestCount: manifest.requiredIds.length });
})
.catch(() => {});
}, [allItems]);
const [answers, setAnswers] = React.useState({});
const PAGE_SIZE = 3;
const [pageIdx, setPageIdx] = React.useState(0);
const startTimeRef = React.useRef(null);
const total = allItems.length;
const totalPages = Math.ceil(total / PAGE_SIZE);
const pageItems = allItems.slice(pageIdx * PAGE_SIZE, (pageIdx + 1) * PAGE_SIZE);
const answered = Object.keys(answers).length;
const pct = total ? (answered / total) * 100 : 0;
const pageFullyAnswered = pageItems.every(it => answers[it.id] != null);
const allAnswered = answered === total;
const setVal = (itemId, v) => {
setAnswers(prev => ({ ...prev, [itemId]: v }));
};
React.useEffect(() => {
if (phase !== 'questions') return;
if (!pageFullyAnswered || pageIdx >= totalPages - 1) return;
const t = setTimeout(() => {
setPageIdx(p => p + 1);
window.scrollTo(0, 0);
}, 600);
return () => clearTimeout(t);
}, [pageFullyAnswered, pageIdx, totalPages, phase]);
if (alreadyCompleted) {
return (
◎
Assessment complete
You've already completed the Kalibr assessment. Your results are in your reports.
);
}
// Dev tool: randomly fill all items
const devFill = () => {
const next = {};
for (const it of allItems) {
if (it.kind === 'likert') {
const r = Math.random();
next[it.id] = r < 0.10 ? 1 : r < 0.30 ? 2 : r < 0.55 ? 3 : r < 0.85 ? 4 : 5;
} else {
const letters = it.letters || ['A', 'B', 'C', 'D', 'E'].slice(0, it.options.length);
next[it.id] = letters[Math.floor(Math.random() * letters.length)];
}
}
setAnswers(next);
setPageIdx(totalPages - 1);
};
// [EDIT 2026-05-11] finish() — uses real user ID, computes quality metrics client-side, calls backend /score Qwen 5/12
const finish = async () => {
if (submitting) return;
setSubmitting(true);
try {
// Use logged-in user's ID so profile/reports are tied to their account
let respondentId;
let participantName, participantEmail, participantRole, participantOrg;
try {
const user = JSON.parse(localStorage.getItem('kalibr.user') || '{}');
respondentId = user.id;
participantName = user.name || null;
participantEmail = user.email || null;
participantRole = user.role || null;
participantOrg = user.org || null;
} catch(e) {}
respondentId = respondentId || ('user_' + Date.now());
// Client-side quality metrics (backend may refine these on /score)
const likertVals = Object.values(answers).filter(v => typeof v === 'number');
const pctMidpoint = likertVals.length ? Math.round(likertVals.filter(v => v === 3).length / likertVals.length * 100) : 0;
const pctAgreement = likertVals.length ? Math.round(likertVals.filter(v => v >= 4).length / likertVals.length * 100) : 0;
const elapsedMs = startTimeRef.current != null ? Date.now() - startTimeRef.current : null;
const completionMinutes = elapsedMs != null ? Math.round(elapsedMs / 6000) / 10 : null;
const card = {
respondent: { id: respondentId },
rawResponses: answers,
quality: { pctMidpoint, pctAgreement, completionMinutes },
};
const existing = JSON.parse(localStorage.getItem('kalibr.be.cards') || '{}');
existing[respondentId] = { email: participantEmail || 'anonymous', card, at: Date.now() };
localStorage.setItem('kalibr.be.cards', JSON.stringify(existing));
// Send responses and demographic data to backend for scoring and persistence
await fetch('/score', {
method: 'POST',
headers: window.kalibrAuth.getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
responses: answers,
respondent_id: respondentId,
participant_name: participantName,
participant_email: participantEmail,
participant_role: participantRole,
participant_org: participantOrg,
}),
}).catch(err => console.warn('[Kalibr] /score request failed:', err));
} catch (e) {
console.warn('[Kalibr] Could not persist to localStorage:', e);
}
onEnter('completion');
};
// [EDIT 2026-05-11] Show onboarding steps before questions begin
if (phase === 'onboarding') {
const s = ONBOARDING_STEPS[step];
const last = step === ONBOARDING_STEPS.length - 1;
return (
{ONBOARDING_STEPS.map((_, i) => (
))}
{s.icon}
{s.title}
{s.body}
{last && (
{[
['data', 'Store my responses for scoring and report generation.'],
['pair', 'Let me invite a partner to unlock the pairwise reports.'],
['ai', 'Generate a personal AI constitution and let me copy it off-platform.'],
].map(([k, v]) => (
))}
)}
{last
?
:
}
);
}
// [EDIT 2026-05-11] Loading guard now checks pageItems instead of single item
if (!pageItems.length) {
return (
Answer honestly. The engine runs response-quality checks — if more than 55% of your answers sit at the midpoint, your report gets flagged as Central Tendency.