159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
import type { SafetyQuiz } from '@/shared/types/app';
|
|
import type {
|
|
SafetyQuizCompletionSummary,
|
|
SafetyQuizComplianceRow,
|
|
} from '@/business/safety-quiz/types';
|
|
import { toWeekStartIso } from '@/shared/business/week';
|
|
|
|
export function getCurrentSafetyQuizWeek(date: Date): string {
|
|
// Shared American (Sunday-start) canonicalization — same util as the dashboard
|
|
// hero and F.R.A.M.E. (behavior unchanged: this was already Sunday-based).
|
|
return toWeekStartIso(date);
|
|
}
|
|
|
|
export function calculateSafetyQuizScore(
|
|
quiz: SafetyQuiz,
|
|
answers: readonly number[],
|
|
): number {
|
|
return answers.filter((answer, index) => answer === quiz.questions[index]?.correctIndex).length;
|
|
}
|
|
|
|
export function calculateSafetyQuizProgress(
|
|
currentQuestionIndex: number,
|
|
questionCount: number,
|
|
): number {
|
|
if (questionCount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.round(((currentQuestionIndex + 1) / questionCount) * 100);
|
|
}
|
|
|
|
export function getSafetyQuizFeedback(
|
|
score: number,
|
|
totalQuestions: number,
|
|
): string {
|
|
if (score === totalQuestions) {
|
|
return 'Perfect score! Outstanding safety knowledge.';
|
|
}
|
|
|
|
if (score >= 3) {
|
|
return 'Great job! Review the explanations for any missed questions.';
|
|
}
|
|
|
|
return 'Please review the material and retake when ready.';
|
|
}
|
|
|
|
export function calculateSafetyQuizCompletionSummary(
|
|
complianceRows: readonly SafetyQuizComplianceRow[],
|
|
): SafetyQuizCompletionSummary {
|
|
const completedCount = complianceRows.filter((row) => row.status === 'complete').length;
|
|
const totalStaff = complianceRows.length;
|
|
|
|
return {
|
|
completedCount,
|
|
totalStaff,
|
|
pendingCount: Math.max(totalStaff - completedCount, 0),
|
|
completionRate: totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0,
|
|
};
|
|
}
|
|
|
|
export function serializeSafetyQuizPayload(payload: SafetyQuiz): string {
|
|
return JSON.stringify(payload, null, 2);
|
|
}
|
|
|
|
export function parseSafetyQuizPayload(draft: string): SafetyQuiz | string {
|
|
try {
|
|
const parsed: unknown = JSON.parse(draft);
|
|
return getSafetyQuizPayloadValidationResult(parsed);
|
|
} catch (error) {
|
|
return error instanceof Error && error.message ? error.message : 'Safety quiz JSON is invalid.';
|
|
}
|
|
}
|
|
|
|
export function validateSafetyQuizPayload(payload: SafetyQuiz): string | null {
|
|
const result = getSafetyQuizPayloadValidationResult(payload);
|
|
return typeof result === 'string' ? result : null;
|
|
}
|
|
|
|
function getSafetyQuizPayloadValidationResult(value: unknown): SafetyQuiz | string {
|
|
if (!isRecord(value)) {
|
|
return 'Safety quiz payload must be a JSON object.';
|
|
}
|
|
|
|
if (!isNonEmptyString(value.id) || !isNonEmptyString(value.title)) {
|
|
return 'Safety quiz payload must include id and title.';
|
|
}
|
|
|
|
if (!isSafetyQuizFocus(value.focus)) {
|
|
return 'Safety quiz focus must be physical-management, de-escalation, or safety-reminders.';
|
|
}
|
|
|
|
if (!isRecord(value.weeklyFocus)
|
|
|| !isNonEmptyString(value.weeklyFocus.title)
|
|
|| !isNonEmptyString(value.weeklyFocus.description)) {
|
|
return 'Weekly focus must include title and description.';
|
|
}
|
|
|
|
const keyReminders = value.keyReminders;
|
|
if (!Array.isArray(keyReminders) || !keyReminders.every(isNonEmptyString)) {
|
|
return 'Key reminders must be an array of non-empty strings.';
|
|
}
|
|
|
|
const questions = value.questions;
|
|
if (!Array.isArray(questions) || questions.length === 0) {
|
|
return 'Questions must be a non-empty array.';
|
|
}
|
|
|
|
for (const question of questions) {
|
|
if (!isValidSafetyQuizQuestion(question)) {
|
|
return 'Each question must include id, question, options, correctIndex, and explanation.';
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: value.id,
|
|
title: value.title,
|
|
focus: value.focus,
|
|
weeklyFocus: {
|
|
title: value.weeklyFocus.title,
|
|
description: value.weeklyFocus.description,
|
|
},
|
|
keyReminders,
|
|
questions,
|
|
};
|
|
}
|
|
|
|
function isValidSafetyQuizQuestion(value: unknown): value is SafetyQuiz['questions'][number] {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
|
|
const correctIndex = value.correctIndex;
|
|
|
|
return isNonEmptyString(value.id)
|
|
&& isNonEmptyString(value.question)
|
|
&& Array.isArray(value.options)
|
|
&& value.options.length > 0
|
|
&& value.options.every(isNonEmptyString)
|
|
&& typeof correctIndex === 'number'
|
|
&& Number.isInteger(correctIndex)
|
|
&& correctIndex >= 0
|
|
&& correctIndex < value.options.length
|
|
&& isNonEmptyString(value.explanation);
|
|
}
|
|
|
|
function isSafetyQuizFocus(value: unknown): value is SafetyQuiz['focus'] {
|
|
return value === 'physical-management'
|
|
|| value === 'de-escalation'
|
|
|| value === 'safety-reminders';
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isNonEmptyString(value: unknown): value is string {
|
|
return typeof value === 'string' && value.trim().length > 0;
|
|
}
|