add changes

This commit is contained in:
AD2025
2025-12-20 00:14:28 +02:00
parent 665919c1e2
commit 079c10e843
6 changed files with 381 additions and 273 deletions

View File

@@ -3,12 +3,12 @@ import { Question } from './question.model';
export interface QuizSessionHistory {
"time": {
"spent": number,
"limit": number | null,
"percentage": number
time: {
spent: number,
limit: number | null,
percentage: number
},
"createdAt": "2025-12-19T18:49:58.000Z"
createdAt: string;
id: string;
category?: {
@@ -111,7 +111,7 @@ export interface QuizStartResponse {
*/
export interface QuizAnswerSubmission {
questionId: string;
answer: string | string[];
userAnswer: string | string[];
quizSessionId: string;
timeSpent?: number;
}
@@ -146,18 +146,122 @@ export interface QuizResults {
questions: QuizQuestionResult[];
}
// Response from /complete endpoint - questions are statistics
export interface CompletedQuizResult {
sessionId: string;
status: string;
category: {
id: string;
name: string;
slug: string;
icon: string;
color: string
},
quizType: string
difficulty: string
score: {
earned: number,
total: number,
percentage: number
},
questions: {
total: number,
answered: number,
correct: number,
incorrect: number,
unanswered: number
},
accuracy: number,
isPassed: boolean,
time: {
started: string,
completed: string,
taken: number,
limit: number,
isTimeout: boolean
}
}
export interface CompletedQuizResponse {
success: boolean
data: CompletedQuizResult
}
// Response from /review endpoint - questions are detailed array
export interface QuizReviewResult {
session: {
id: string;
status: string;
quizType: string;
difficulty: string;
category: {
id: string;
name: string;
slug: string;
icon: string;
color: string;
};
startedAt: string;
completedAt: string;
timeSpent: number;
};
summary: {
score: {
earned: number;
total: number;
percentage: number;
};
questions: {
total: number;
answered: number;
correct: number;
incorrect: number;
unanswered: number;
};
accuracy: number;
isPassed: boolean;
timeStatistics: {
totalTime: number;
averageTimePerQuestion: number;
timeLimit: number | null;
wasTimedOut: boolean;
};
};
questions: QuizQuestionResult[];
}
export interface QuizReviewResponse {
success: boolean;
data: QuizReviewResult;
message?: string;
}
/**
* Quiz Question Result
*/
export interface QuizQuestionResult {
questionId: string;
id: string;
questionText: string;
questionType: string;
userAnswer: string | string[];
correctAnswer: string | string[];
isCorrect: boolean;
explanation: string;
options: any;
difficulty: string;
points: number;
explanation: string;
tags: string[];
order: number;
correctAnswer: string | string[];
userAnswer: string | string[] | null;
isCorrect: boolean | null;
resultStatus: 'correct' | 'incorrect' | 'unanswered';
pointsEarned: number;
pointsPossible: number;
timeTaken: number | null;
answeredAt: string | null;
showExplanation: boolean;
wasAnswered: boolean;
// Legacy support
questionId?: string;
timeSpent?: number;
}

View File

@@ -10,7 +10,11 @@ import {
QuizAnswerSubmission,
QuizAnswerResponse,
QuizResults,
QuizStartFormRequest
QuizStartFormRequest,
CompletedQuizResult,
CompletedQuizResponse,
QuizReviewResult,
QuizReviewResponse
} from '../models/quiz.model';
import { ToastService } from './toast.service';
import { StorageService } from './storage.service';
@@ -37,7 +41,7 @@ export class QuizService {
readonly questions = this._questions.asReadonly();
// Quiz results state
private readonly _quizResults = signal<QuizResults | null>(null);
private readonly _quizResults = signal<CompletedQuizResult | QuizReviewResult | QuizResults | null>(null);
readonly quizResults = this._quizResults.asReadonly();
// Loading states
@@ -131,7 +135,22 @@ export class QuizService {
submitAnswer(submission: QuizAnswerSubmission): Observable<QuizAnswerResponse> {
this._isSubmittingAnswer.set(true);
return this.http.post<QuizAnswerResponse>(`${this.apiUrl}/submit`, submission).pipe(
return this.http.post<any>(`${this.apiUrl}/submit`, submission).pipe(
map(response => {
// Backend returns: { success, data: { isCorrect, pointsEarned, feedback: { explanation, correctAnswer }, sessionProgress: { currentScore } } }
// Frontend expects: { success, isCorrect, correctAnswer, explanation, points, score }
const backendData = response.data;
const mappedResponse: QuizAnswerResponse = {
success: response.success,
isCorrect: backendData.isCorrect,
correctAnswer: backendData.feedback?.correctAnswer || '',
explanation: backendData.feedback?.explanation || '',
points: backendData.pointsEarned || 0,
score: backendData.sessionProgress?.currentScore || 0,
message: response.message
};
return mappedResponse;
}),
tap(response => {
if (response.success) {
// Update session state
@@ -163,13 +182,13 @@ export class QuizService {
/**
* Complete the quiz session
*/
completeQuiz(sessionId: string): Observable<QuizResults> {
completeQuiz(sessionId: string): Observable<CompletedQuizResponse> {
this._isCompletingQuiz.set(true);
return this.http.post<QuizResults>(`${this.apiUrl}/complete`, { sessionId }).pipe(
return this.http.post<CompletedQuizResponse>(`${this.apiUrl}/complete`, { sessionId }).pipe(
tap(results => {
if (results.success) {
this._quizResults.set(results);
this._quizResults.set(results.data);
// Update session status
const currentSession = this._activeSession();
@@ -220,11 +239,11 @@ export class QuizService {
/**
* Get quiz review data
*/
reviewQuiz(sessionId: string): Observable<QuizResults> {
return this.http.get<QuizResults>(`${this.apiUrl}/review/${sessionId}`).pipe(
reviewQuiz(sessionId: string): Observable<QuizReviewResponse> {
return this.http.get<QuizReviewResponse>(`${this.apiUrl}/review/${sessionId}`).pipe(
tap(results => {
if (results.success) {
this._quizResults.set(results);
this._quizResults.set(results.data);
}
}),
catchError(error => {

View File

@@ -102,10 +102,10 @@
</h3>
</div>
@if (!answerResult()?.isCorrect) {
@if (answerResult() && !answerResult()!.isCorrect) {
<div class="correct-answer">
<strong>Correct Answer:</strong>
<p>{{ answerResult()?.correctAnswer }}</p>
<p>{{ answerResult()!.correctAnswer }}</p>
</div>
}
@@ -123,9 +123,6 @@
</div>
}
{{this.answerForm?.valid }}
{{ !this.answerSubmitted()}}
{{ !this.isSubmittingAnswer()}}
<!-- Action Buttons -->
<div class="action-buttons">
@if (!answerSubmitted()) {

View File

@@ -269,7 +269,7 @@ export class QuizQuestionComponent implements OnInit, OnDestroy {
const submission: QuizAnswerSubmission = {
questionId: question.id,
answer: answer,
userAnswer: answer,
quizSessionId: this.sessionId
};

View File

@@ -1,243 +1,208 @@
<div class="quiz-review-container">
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading review...</p>
</div>
<div class="loading-container">
<mat-spinner diameter="50"></mat-spinner>
<p>Loading review...</p>
</div>
}
<!-- Review Content -->
@if (!isLoading() && results()) {
<div class="review-content">
<!-- Header Section -->
<div class="review-header">
<button mat-icon-button (click)="backToResults()" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="header-content">
<h1 class="review-title">Quiz Review</h1>
<p class="review-subtitle">Review your answers and learn from mistakes</p>
</div>
</div>
<!-- Summary Cards -->
<div class="summary-cards">
<mat-card class="summary-card">
<mat-card-content>
<div class="card-icon total">
<mat-icon>quiz</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ allQuestions().length }}</div>
<div class="card-label">Total Questions</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card correct">
<mat-card-content>
<div class="card-icon success">
<mat-icon>check_circle</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ correctCount() }}</div>
<div class="card-label">Correct</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card incorrect">
<mat-card-content>
<div class="card-icon error">
<mat-icon>cancel</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ incorrectCount() }}</div>
<div class="card-label">Incorrect</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card score">
<mat-card-content>
<div class="card-icon primary">
<mat-icon>emoji_events</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ results()!.percentage }}%</div>
<div class="card-label">Score</div>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button
mat-stroked-button
[class.active]="filterType() === 'all'"
(click)="setFilter('all')"
>
<mat-icon>list</mat-icon>
All Questions ({{ allQuestions().length }})
</button>
<button
mat-stroked-button
[class.active]="filterType() === 'correct'"
(click)="setFilter('correct')"
class="correct-tab"
>
<mat-icon>check_circle</mat-icon>
Correct ({{ correctCount() }})
</button>
<button
mat-stroked-button
[class.active]="filterType() === 'incorrect'"
(click)="setFilter('incorrect')"
class="incorrect-tab"
>
<mat-icon>cancel</mat-icon>
Incorrect ({{ incorrectCount() }})
</button>
</div>
<!-- Questions List -->
<div class="questions-list">
@for (question of paginatedQuestions(); track question.questionId; let i = $index) {
<mat-card class="question-card" [class.incorrect]="!question.isCorrect">
<mat-card-header>
<div class="question-header-content">
<div class="question-number-badge" [class.correct]="question.isCorrect">
<span class="number">{{ (pageIndex() * pageSize()) + i + 1 }}</span>
<mat-icon class="status-icon">
{{ question.isCorrect ? 'check_circle' : 'cancel' }}
</mat-icon>
</div>
<div class="question-meta">
<mat-chip class="type-chip">
{{ getQuestionTypeText(question.questionType) }}
</mat-chip>
<span class="points-badge">{{ question.points }} pts</span>
</div>
@if (isAuthenticated()) {
<button
mat-icon-button
class="bookmark-btn"
[class.bookmarked]="isBookmarked(question.questionId)"
(click)="toggleBookmark(question.questionId)"
[matTooltip]="isBookmarked(question.questionId) ? 'Remove bookmark' : 'Bookmark question'"
>
<mat-icon>
{{ isBookmarked(question.questionId) ? 'bookmark' : 'bookmark_border' }}
</mat-icon>
</button>
}
</div>
</mat-card-header>
<mat-card-content>
<div class="question-text">{{ question.questionText }}</div>
<mat-divider></mat-divider>
<div class="answer-section">
<div class="answer-row">
<div class="answer-label">Your Answer:</div>
<div class="answer-value" [class.incorrect]="!question.isCorrect">
{{ formatAnswer(question.userAnswer) || 'Not answered' }}
@if (!question.isCorrect) {
<mat-icon class="answer-icon error">close</mat-icon>
} @else {
<mat-icon class="answer-icon success">check</mat-icon>
}
</div>
</div>
@if (!question.isCorrect) {
<div class="answer-row correct-answer">
<div class="answer-label">Correct Answer:</div>
<div class="answer-value correct">
{{ formatAnswer(question.correctAnswer) }}
<mat-icon class="answer-icon success">check</mat-icon>
</div>
</div>
}
</div>
@if (question.explanation) {
<div class="explanation-section">
<div class="explanation-header">
<mat-icon>lightbulb</mat-icon>
<span>Explanation</span>
</div>
<p class="explanation-text">{{ question.explanation }}</p>
</div>
}
@if (question.timeSpent) {
<div class="time-spent">
<mat-icon>schedule</mat-icon>
<span>Time spent: {{ question.timeSpent }}s</span>
</div>
}
</mat-card-content>
</mat-card>
}
@if (paginatedQuestions().length === 0) {
<div class="empty-state">
<mat-icon>info</mat-icon>
<p>No questions match the selected filter</p>
</div>
}
</div>
<!-- Pagination -->
@if (totalQuestions() > pageSize()) {
<mat-paginator
[length]="totalQuestions()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[5, 10, 20, 50]"
(page)="onPageChange($event)"
showFirstLastButtons
>
</mat-paginator>
}
<!-- Action Buttons -->
<div class="action-buttons">
<button
mat-raised-button
(click)="backToResults()"
class="action-btn"
>
<mat-icon>arrow_back</mat-icon>
Back to Results
</button>
<button
mat-raised-button
color="primary"
(click)="retakeQuiz()"
class="action-btn"
>
<mat-icon>refresh</mat-icon>
Retake Quiz
</button>
<button
mat-raised-button
(click)="goToDashboard()"
class="action-btn"
>
<mat-icon>dashboard</mat-icon>
Dashboard
</button>
<div class="review-content">
<!-- Header Section -->
<div class="review-header">
<button mat-icon-button (click)="backToResults()" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="header-content">
<h1 class="review-title">Quiz Review</h1>
<p class="review-subtitle">Review your answers and learn from mistakes</p>
</div>
</div>
<!-- Summary Cards -->
<div class="summary-cards">
<mat-card class="summary-card">
<mat-card-content>
<div class="card-icon total">
<mat-icon>quiz</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ allQuestions().length }}</div>
<div class="card-label">Total Questions</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card correct">
<mat-card-content>
<div class="card-icon success">
<mat-icon>check_circle</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ correctCount() }}</div>
<div class="card-label">Correct</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card incorrect">
<mat-card-content>
<div class="card-icon error">
<mat-icon>cancel</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ incorrectCount() }}</div>
<div class="card-label">Incorrect</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="summary-card score">
<mat-card-content>
<div class="card-icon primary">
<mat-icon>emoji_events</mat-icon>
</div>
<div class="card-info">
<div class="card-value">{{ scorePercentage() }}%</div>
<div class="card-label">Score</div>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<button mat-stroked-button [class.active]="filterType() === 'all'" (click)="setFilter('all')">
<mat-icon>list</mat-icon>
All Questions ({{ allQuestions().length }})
</button>
<button mat-stroked-button [class.active]="filterType() === 'correct'" (click)="setFilter('correct')"
class="correct-tab">
<mat-icon>check_circle</mat-icon>
Correct ({{ correctCount() }})
</button>
<button mat-stroked-button [class.active]="filterType() === 'incorrect'" (click)="setFilter('incorrect')"
class="incorrect-tab">
<mat-icon>cancel</mat-icon>
Incorrect ({{ incorrectCount() }})
</button>
</div>
<!-- Questions List -->
<div class="questions-list">
@for (question of paginatedQuestions(); track question.id; let i = $index) {
<mat-card class="question-card" [class.incorrect]="!question.isCorrect">
<mat-card-header>
<div class="question-header-content">
<div class="question-number-badge" [class.correct]="question.isCorrect">
<span class="number">{{ (pageIndex() * pageSize()) + i + 1 }}</span>
<mat-icon class="status-icon">
{{ question.isCorrect ? 'check_circle' : 'cancel' }}
</mat-icon>
</div>
<div class="question-meta">
<mat-chip class="type-chip">
{{ getQuestionTypeText(question.questionType) }}
</mat-chip>
<span class="points-badge">{{ question.points }} pts</span>
</div>
@if (isAuthenticated()) {
<button mat-icon-button class="bookmark-btn" [class.bookmarked]="isBookmarked(question.id)"
(click)="toggleBookmark(question.id)"
[matTooltip]="isBookmarked(question.id) ? 'Remove bookmark' : 'Bookmark question'">
<mat-icon>
{{ isBookmarked(question.id) ? 'bookmark' : 'bookmark_border' }}
</mat-icon>
</button>
}
</div>
</mat-card-header>
<mat-card-content>
<div class="question-text">{{ question.questionText }}</div>
<mat-divider></mat-divider>
<div class="answer-section">
<div class="answer-row">
<div class="answer-label">Your Answer:</div>
<div class="answer-value" [class.incorrect]="!question.isCorrect">
{{ formatAnswer(question.userAnswer ?? '') || 'Not answered' }}
@if (!question.isCorrect) {
<mat-icon class="answer-icon error">close</mat-icon>
} @else {
<mat-icon class="answer-icon success">check</mat-icon>
}
</div>
</div>
@if (!question.isCorrect) {
<div class="answer-row correct-answer">
<div class="answer-label">Correct Answer:</div>
<div class="answer-value correct">
{{ formatAnswer(question.correctAnswer) }}
<mat-icon class="answer-icon success">check</mat-icon>
</div>
</div>
}
</div>
@if (question.explanation) {
<div class="explanation-section">
<div class="explanation-header">
<mat-icon>lightbulb</mat-icon>
<span>Explanation</span>
</div>
<p class="explanation-text">{{ question.explanation }}</p>
</div>
}
@if (question.timeSpent) {
<div class="time-spent">
<mat-icon>schedule</mat-icon>
<span>Time spent: {{ question.timeSpent }}s</span>
</div>
}
</mat-card-content>
</mat-card>
}
@if (paginatedQuestions().length === 0) {
<div class="empty-state">
<mat-icon>info</mat-icon>
<p>No questions match the selected filter</p>
</div>
}
</div>
<!-- Pagination -->
@if (totalQuestions() > pageSize()) {
<mat-paginator [length]="totalQuestions()" [pageSize]="pageSize()" [pageIndex]="pageIndex()"
[pageSizeOptions]="[5, 10, 20, 50]" (page)="onPageChange($event)" showFirstLastButtons>
</mat-paginator>
}
<!-- Action Buttons -->
<div class="action-buttons">
<button mat-raised-button (click)="backToResults()" class="action-btn">
<mat-icon>arrow_back</mat-icon>
Back to Results
</button>
<button mat-raised-button color="primary" (click)="retakeQuiz()" class="action-btn">
<mat-icon>refresh</mat-icon>
Retake Quiz
</button>
<button mat-raised-button (click)="goToDashboard()" class="action-btn">
<mat-icon>dashboard</mat-icon>
Dashboard
</button>
</div>
</div>
}
</div>

View File

@@ -12,7 +12,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Subject, takeUntil } from 'rxjs';
import { QuizService } from '../../../core/services/quiz.service';
import { StorageService } from '../../../core/services/storage.service';
import { QuizResults, QuizQuestionResult } from '../../../core/models/quiz.model';
import { QuizReviewResult, QuizQuestionResult } from '../../../core/models/quiz.model';
@Component({
selector: 'app-quiz-review',
@@ -49,8 +49,15 @@ export class QuizReviewComponent implements OnInit, OnDestroy {
// Filter
readonly filterType = signal<'all' | 'correct' | 'incorrect'>('all');
// Computed values
readonly allQuestions = computed(() => this.results()?.questions ?? []);
// Computed values - handle QuizReviewResult structure
readonly allQuestions = computed(() => {
const res = this.results();
// Check if it's QuizReviewResult (has questions array)
if (res && 'questions' in res && Array.isArray(res.questions)) {
return res.questions;
}
return [];
});
readonly filteredQuestions = computed(() => {
const questions = this.allQuestions();
@@ -80,6 +87,22 @@ export class QuizReviewComponent implements OnInit, OnDestroy {
this.allQuestions().filter(q => !q.isCorrect).length
);
readonly scorePercentage = computed(() => {
const res = this.results();
if (res && 'summary' in res) {
return res.summary.score.percentage;
}
return 0;
});
readonly sessionInfo = computed(() => {
const res = this.results();
if (res && 'session' in res) {
return res.session;
}
return null;
});
readonly isAuthenticated = computed(() =>
this.storageService.isAuthenticated()
);