Files
Nexus.Reader/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor
T
Antigravity aa80c2ba3e feat(ui/quiz): implement real-time global chapter quiz generation, submit results to database, and display dynamic statistics on dashboard (#53)
This PR fully implements the Global Chapter-Level Quiz Generation system in the NexusReader application.

### Key Accomplishments:
1. **SubmitQuizResultCommand**: Added MediatR command and handler to persist completed quiz results to the SQLite database securely, using our clean architecture result-pattern.
2. **Dynamic Dashboard Integration**: Re-engineered the user dashboard to fetch, calculate, and display real-time statistics (average score, total books read, total concept nodes mapped, and list of resolved quizzes with their dates and scores) directly from active database queries, eliminating static mockups.
3. **Haptic & Visual Feedback**: Enhanced the quiz flow with interactive CSS transitions, glowing hover feedback, and clear result visualization upon completion.
4. **Robust Verification**: Implemented comprehensive unit tests for `SubmitQuizResultCommandHandler` covering all success and failure/edge cases. Executed full `dotnet test` with 100% success rate.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #53
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-26 11:43:58 +00:00

248 lines
8.4 KiB
Plaintext

@using MediatR
@using NexusReader.Application.Queries.Quiz
@using NexusReader.Application.Commands.Quiz
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services
@inject IMediator Mediator
@inject IPlatformService PlatformService
@inject IQuizStateService QuizService
@inject IIdentityService IdentityService
@inject IKnowledgeGraphService GraphService
@inject KnowledgeCoordinator Coordinator
<div class="knowledge-check">
<div class="quiz-header">
<span class="header-title">Sprawdzian Wiedzy</span>
<button class="expand-btn">⌵</button>
</div>
@if (QuizService.IsHydrating || _isGenerating)
{
<div class="loading-state shimmer">Skanowanie wiedzy przez AI...</div>
}
else if (_isSubmitted)
{
<div class="submitted-container">
<div class="success-icon-wrapper">
<NexusIcon Name="check" Size="48" Class="success-glow" />
</div>
<h3 class="submitted-title">Gratulacje!</h3>
<p class="submitted-text">Sprawdzian zakończony pomyślnie. Twój wynik został zapisany w bazie danych.</p>
<div class="score-card">
<div class="score-main">
<span class="score-num">@_score</span>
<span class="score-divider">/</span>
<span class="score-total">@_totalQuestions</span>
</div>
<div class="score-percent">@((int)_percentage)% poprawnych odpowiedzi</div>
</div>
<button class="reset-quiz-btn" @onclick="CloseQuiz">
<span>ZAKOŃCZ</span>
</button>
</div>
}
else if (QuizService.CurrentQuiz != null)
{
<div class="quiz-body">
@foreach (var question in QuizService.CurrentQuiz.Questions)
{
<div class="question-container">
<p class="question-text">@question.Question</p>
<div class="options-list">
@for (int i = 0; i < question.Options.Count; i++)
{
var index = i;
var letter = (char)('A' + i);
<button class="option-item @GetOptionClass(question, index)"
@onclick="() => SelectOptionAsync(question, index)"
disabled="@_states.ContainsKey(question)">
<span class="option-letter">@letter)</span>
<span class="option-text">@question.Options[index]</span>
</button>
}
</div>
</div>
}
<div class="quiz-footer">
<button class="submit-btn" disabled="@(!AllQuestionsAnswered() || _isSubmitting)" @onclick="SubmitQuizAsync">
@if (_isSubmitting)
{
<span>Zapisywanie...</span>
}
else
{
<span>Wyślij</span>
}
</button>
</div>
</div>
}
else
{
<div class="empty-quiz-state">
<div class="empty-icon-wrapper">
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
</div>
<h3 class="empty-title">Brak Aktywnego Quizu</h3>
<p class="empty-text">Generuj spersonalizowany sprawdzian wiedzy na podstawie bieżącego rozdziału książki.</p>
<button class="generate-quiz-btn" @onclick="GenerateChapterQuizAsync" disabled="@(string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent))">
<span>GENERUJ QUIZ DLA ROZDZIAŁU</span>
</button>
</div>
}
</div>
@code {
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
private bool _isSubmitting = false;
private bool _isSubmitted = false;
private bool _isGenerating = false;
private int _score = 0;
private int _totalQuestions = 0;
private double _percentage = 0.0;
protected override void OnInitialized()
{
QuizService.OnQuizUpdated += HandleUpdate;
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
{
QuizService.OnQuizUpdated -= HandleUpdate;
}
private async Task GenerateChapterQuizAsync()
{
if (_isGenerating || string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent)) return;
_isGenerating = true;
StateHasChanged();
try
{
await Coordinator.RequestSummaryAndQuizAsync(Coordinator.CurrentFullPageContent);
}
finally
{
_isGenerating = false;
StateHasChanged();
}
}
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
{
if (_states.ContainsKey(question)) return;
// Haptic feedback
await PlatformService.VibrateAsync(40);
var cmd = new SubmitAnswerCommand(index, question.CorrectIndex);
var res = await Mediator.Send(cmd);
_states[question] = (index, res.IsSuccess);
if (res.IsSuccess)
await PlatformService.VibrateSuccessAsync();
else
await PlatformService.VibrateErrorAsync();
StateHasChanged();
}
private bool AllQuestionsAnswered()
{
return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count;
}
private async Task SubmitQuizAsync()
{
if (QuizService.CurrentQuiz == null || !AllQuestionsAnswered() || _isSubmitting) return;
_isSubmitting = true;
StateHasChanged();
try
{
_score = _states.Values.Count(s => s.IsCorrect);
_totalQuestions = QuizService.CurrentQuiz.Questions.Count;
_percentage = _totalQuestions > 0 ? ((double)_score / _totalQuestions) * 100 : 0.0;
string topic = "Quiz wiedzy";
var graph = GraphService.CurrentGraphData;
if (graph != null && !string.IsNullOrEmpty(QuizService.CurrentQuizBlockId))
{
var node = graph.Nodes.FirstOrDefault(n => n.Id == QuizService.CurrentQuizBlockId);
if (node != null && !string.IsNullOrEmpty(node.Label))
{
topic = $"Test: {node.Label}";
}
}
var profileResult = await IdentityService.GetProfileAsync();
if (profileResult.IsSuccess && profileResult.Value != null)
{
var userId = profileResult.Value.UserId;
var cmd = new SubmitQuizResultCommand(userId, topic, _score, _totalQuestions);
var result = await Mediator.Send(cmd);
if (result.IsSuccess)
{
IdentityService.ClearCache();
_isSubmitted = true;
await PlatformService.VibrateSuccessAsync();
}
else
{
await PlatformService.VibrateErrorAsync();
}
}
}
catch
{
await PlatformService.VibrateErrorAsync();
}
finally
{
_isSubmitting = false;
StateHasChanged();
}
}
private void CloseQuiz()
{
_isSubmitted = false;
_states.Clear();
QuizService.SetQuiz(null, null);
}
private string GetBlockClass(QuizQuestionDto question)
{
if (!_states.TryGetValue(question, out var state)) return "";
return state.IsCorrect ? "state-correct" : "state-incorrect";
}
private string GetOptionClass(QuizQuestionDto question, int index)
{
if (!_states.TryGetValue(question, out var state)) return "";
if (state.SelectedIndex == index)
return state.IsCorrect ? "option-correct" : "option-incorrect";
if (state.IsCorrect == false && question.CorrectIndex == index)
return "option-revealed-correct";
return "option-faded";
}
}