Files
Arbejd.com-react/src/mvvm/viewmodels/SimulatorEvaluationViewModel.ts
2026-03-04 22:23:10 +01:00

323 lines
11 KiB
TypeScript

import { SimulationService } from '../services/simulation.service';
interface RecordLike {
[key: string]: unknown;
}
export interface SimulatorEvaluationSelectionContext {
companyName?: string;
dateLabel?: string;
title?: string;
}
export interface EvaluationImprovementItem {
id: string;
title: string;
behavior: string;
effect: string;
nextStep: string;
}
export interface SimulatorEvaluationData {
companyName: string;
dateLabel: string;
evaluationLabel: string;
interviewerEvaluationBody: string;
interviewerEvaluationLead: string;
interviewerEvaluationTitle: string;
interviewerScore: number;
interviewTitle: string;
recommendations: string[];
selfScore: number;
strengths: string[];
suggestions: EvaluationImprovementItem[];
}
function asRecord(value: unknown): RecordLike | null {
return typeof value === 'object' && value !== null ? (value as RecordLike) : null;
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => asString(item).trim()).filter(Boolean);
}
function pickString(root: RecordLike | null, keys: string[]): string {
if (!root) {
return '';
}
for (const key of keys) {
const value = asString(root[key]).trim();
if (value) {
return value;
}
}
return '';
}
function pickNumber(root: RecordLike | null, keys: string[]): number | null {
if (!root) {
return null;
}
for (const key of keys) {
const value = asNumber(root[key]);
if (value !== null) {
return value;
}
}
return null;
}
function formatDateLabel(value: string): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('da-DK', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(date);
}
function normalizeScore(value: number | null, fallback: number): number {
if (value === null) {
return fallback;
}
return Math.max(0, Math.min(10, Math.round(value)));
}
const FALLBACK_SUGGESTIONS: EvaluationImprovementItem[] = [
{
id: '1',
title: 'Mere struktur og korthed i dine svar',
behavior: 'Du gav nogle svar, der blev lange og gentagende i stedet for fokuserede pa kernen.',
effect: 'Det kan gore det svaerere for intervieweren hurtigt at vurdere din erfaring og beslutninger.',
nextStep: 'Svar i 3 trin: pointe, konkret eksempel, resultat. Sigt efter 30-60 sekunder pr. svar.',
},
{
id: '2',
title: 'Flere konkrete eksempler pa performance i service',
behavior: 'Du fortalte om ansvar, men gav fa konkrete scenarier med handling og resultat.',
effect: 'Uden konkrete cases bliver niveau og paalidelighed svaerere at validere.',
nextStep: 'Forbered 2-3 STAR-historier med tydelig situation, handling og maelbart resultat.',
},
{
id: '3',
title: 'Gor din motivation mere maelrettet virksomheden',
behavior: 'Motivationen var positiv, men ikke altid koblet direkte til virksomhedens drift og behov.',
effect: 'Du kan fremsta generelt motiveret i stedet for specifikt relevant for rollen.',
nextStep: 'Naevn 2-3 konkrete grunde til, at netop deres setup matcher din erfaring.',
},
{
id: '4',
title: 'Konkretiser kvalitet og sikkerhed i praksis',
behavior: 'Du naevnte standarder og certificeringer, men beskrev fa daglige rutiner.',
effect: 'Det reducerer tydeligheden omkring, hvordan du arbejder sikkert i travle perioder.',
nextStep: 'Beskriv faste rutiner for kontrol, logning og hurtig korrektion under pres.',
},
];
const FALLBACK_STRENGTHS = [
'Du viser relevant erfaring for rollen og kommunikerer ro under pres.',
'Du arbejder struktureret med kvalitet og timing i service.',
'Du har en moden team-tilgang med fokus pa samarbejde.',
'Du kobler dine svar til konkrete arbejdsrutiner og drift.',
'Du fremstar stabil og ansvarlig i hektiske situationer.',
];
const FALLBACK_RECOMMENDATIONS = [
'Forbered 3 korte STAR-historier med maelbare resultater.',
'Lav en 60-sekunders pitch af din profil og vaerdiskabelse.',
'Research virksomheden i 10-15 minutter inden samtalen.',
'Forbered 4-5 konkrete spoergsmaal om rolle og forventninger.',
'Afslut svar med resultat, sa din effekt bliver tydelig.',
];
const FALLBACK_DATA: SimulatorEvaluationData = {
companyName: 'Ukendt virksomhed',
dateLabel: 'Nyligt',
evaluationLabel: 'Interview Feedback',
interviewerEvaluationTitle: 'Interviewer evaluering',
interviewerEvaluationLead: 'Du er godt med. Din praestation viste styrker der matcher stillingen.',
interviewerEvaluationBody:
'Du viste relevante kompetencer og en stabil tilgang under pres. Du kan staerke dit indtryk yderligere ved at svare mere struktureret og bruge flere konkrete resultateksempler.',
interviewTitle: 'Stilling',
interviewerScore: 8,
selfScore: 5,
strengths: FALLBACK_STRENGTHS,
recommendations: FALLBACK_RECOMMENDATIONS,
suggestions: FALLBACK_SUGGESTIONS,
};
function mapSuggestion(item: unknown, index: number): EvaluationImprovementItem | null {
const source = asRecord(item);
if (!source) {
return null;
}
const title = pickString(source, ['title', 'heading', 'name', 'subject']);
const behavior = pickString(source, ['behavior', 'adfaerd', 'observation', 'issue']);
const effect = pickString(source, ['effect', 'impact', 'consequence']);
const nextStep = pickString(source, ['next_step', 'nextStep', 'recommendation', 'suggestion']);
if (!title && !behavior && !effect && !nextStep) {
return null;
}
return {
id: asString(source.id) || String(index + 1),
title: title || `Forbedringspunkt ${index + 1}`,
behavior: behavior || 'Ingen detaljer tilgaengelige.',
effect: effect || 'Ingen detaljer tilgaengelige.',
nextStep: nextStep || 'Ingen detaljer tilgaengelige.',
};
}
function extractSuggestions(root: RecordLike | null): EvaluationImprovementItem[] {
const candidates = [
root?.suggestions,
root?.improvements,
root?.improvement_points,
root?.feedback_points,
asRecord(root?.evaluation)?.suggestions,
asRecord(root?.evaluation)?.improvements,
];
for (const candidate of candidates) {
if (!Array.isArray(candidate)) {
continue;
}
const mapped = candidate
.map((item, index) => mapSuggestion(item, index))
.filter((item): item is EvaluationImprovementItem => Boolean(item));
if (mapped.length > 0) {
return mapped;
}
}
return FALLBACK_SUGGESTIONS;
}
function extractStringList(root: RecordLike | null, keys: string[], fallback: string[]): string[] {
for (const key of keys) {
const values = asStringArray(root?.[key]);
if (values.length > 0) {
return values;
}
}
const evaluation = asRecord(root?.evaluation);
for (const key of keys) {
const values = asStringArray(evaluation?.[key]);
if (values.length > 0) {
return values;
}
}
return fallback;
}
export class SimulatorEvaluationViewModel {
constructor(private readonly simulationService: SimulationService = new SimulationService()) {}
async getEvaluation(interviewId: string, context?: SimulatorEvaluationSelectionContext): Promise<SimulatorEvaluationData> {
try {
const payload = await this.simulationService.getInterviewEvaluation(interviewId);
const root = asRecord(payload);
const evaluation = asRecord(root?.evaluation);
const companyName = pickString(root, ['company_name', 'companyName'])
|| pickString(evaluation, ['company_name', 'companyName'])
|| context?.companyName
|| FALLBACK_DATA.companyName;
const interviewTitle = pickString(root, ['job_title', 'job_name', 'title'])
|| pickString(evaluation, ['job_title', 'job_name', 'title'])
|| context?.title
|| FALLBACK_DATA.interviewTitle;
const rawDate = pickString(root, ['interview_date', 'created_at', 'date'])
|| pickString(evaluation, ['interview_date', 'created_at', 'date']);
const dateLabel = formatDateLabel(rawDate) || context?.dateLabel || FALLBACK_DATA.dateLabel;
const interviewerEvaluationLead = pickString(root, ['interviewer_evaluation_lead', 'lead'])
|| pickString(evaluation, ['interviewer_evaluation_lead', 'lead'])
|| FALLBACK_DATA.interviewerEvaluationLead;
const interviewerEvaluationBody = pickString(root, ['interviewer_evaluation', 'summary', 'feedback'])
|| pickString(evaluation, ['interviewer_evaluation', 'summary', 'feedback'])
|| FALLBACK_DATA.interviewerEvaluationBody;
const interviewerEvaluationTitle = pickString(root, ['evaluation_title', 'interviewer_title'])
|| pickString(evaluation, ['evaluation_title', 'interviewer_title'])
|| FALLBACK_DATA.interviewerEvaluationTitle;
const evaluationLabel = pickString(root, ['label', 'evaluation_label'])
|| pickString(evaluation, ['label', 'evaluation_label'])
|| FALLBACK_DATA.evaluationLabel;
const selfScore = normalizeScore(
pickNumber(root, ['candidate_score', 'self_score', 'self_rating'])
?? pickNumber(evaluation, ['candidate_score', 'self_score', 'self_rating']),
FALLBACK_DATA.selfScore,
);
const interviewerScore = normalizeScore(
pickNumber(root, ['interviewer_score', 'score', 'interviewer_rating'])
?? pickNumber(evaluation, ['interviewer_score', 'score', 'interviewer_rating']),
FALLBACK_DATA.interviewerScore,
);
return {
companyName,
dateLabel,
evaluationLabel,
interviewerEvaluationBody,
interviewerEvaluationLead,
interviewerEvaluationTitle,
interviewerScore,
interviewTitle,
recommendations: extractStringList(root, ['recommendations', 'constructive_recommendations', 'next_steps'], FALLBACK_RECOMMENDATIONS),
selfScore,
strengths: extractStringList(root, ['strengths', 'highlights'], FALLBACK_STRENGTHS),
suggestions: extractSuggestions(root),
};
} catch {
return {
...FALLBACK_DATA,
companyName: context?.companyName || FALLBACK_DATA.companyName,
interviewTitle: context?.title || FALLBACK_DATA.interviewTitle,
dateLabel: context?.dateLabel || FALLBACK_DATA.dateLabel,
};
}
}
async submitRating(interviewId: string, rating: number, comment: string): Promise<void> {
await this.simulationService.submitEvaluationRating(interviewId, rating, comment);
}
}