From f03bcc40e338987e183266066804211fba42bacb Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Mon, 1 Jun 2026 19:08:48 -0300 Subject: [PATCH] timed revision --- .../Controllers/FlashcardController.cs | 15 + .../Models/Flashcards/FlashcardModels.cs | 58 +++ .../Requests/FlashcardReviewAnswerRequest.cs | 8 + .../Repositories/FlashcardRepository.cs | 63 +++ Mindforge.API/Services/FlashcardService.cs | 169 +++++++ .../Interfaces/IFlashcardRepository.cs | 2 + .../Services/Interfaces/IFlashcardService.cs | 2 + Mindforge.Web/src/app.tsx | 6 +- .../components/FlashcardReviewComponent.tsx | 56 ++- Mindforge.Web/src/components/Sidebar.tsx | 11 +- .../src/components/SpacedReviewComponent.css | 223 +++++++++ .../src/components/SpacedReviewComponent.tsx | 422 ++++++++++++++++++ .../src/services/MindforgeApiService.ts | 71 +++ project-context.md | 46 +- 14 files changed, 1138 insertions(+), 14 deletions(-) create mode 100644 Mindforge.API/Models/Requests/FlashcardReviewAnswerRequest.cs create mode 100644 Mindforge.Web/src/components/SpacedReviewComponent.css create mode 100644 Mindforge.Web/src/components/SpacedReviewComponent.tsx diff --git a/Mindforge.API/Controllers/FlashcardController.cs b/Mindforge.API/Controllers/FlashcardController.cs index ffae75c..bc24de7 100644 --- a/Mindforge.API/Controllers/FlashcardController.cs +++ b/Mindforge.API/Controllers/FlashcardController.cs @@ -36,6 +36,13 @@ namespace Mindforge.API.Controllers return Ok(libraries); } + [HttpGet("rag-status")] + public async Task GetRagStatus() + { + var dashboard = await _flashcardService.GetRagStatusAsync(); + return Ok(dashboard); + } + [HttpGet("libraries/{id:long}")] public async Task GetLibraryById([FromRoute] long id) { @@ -55,5 +62,13 @@ namespace Mindforge.API.Controllers var cards = await _flashcardService.BuildReviewSessionAsync(request); return Ok(new { cards }); } + + [HttpPost("review-answer")] + public async Task RecordReviewAnswer([FromBody] FlashcardReviewAnswerRequest request) + { + request ??= new FlashcardReviewAnswerRequest(); + await _flashcardService.RecordReviewAnswerAsync(request.CardId, request.Correct); + return Ok(new { success = true }); + } } } diff --git a/Mindforge.API/Models/Flashcards/FlashcardModels.cs b/Mindforge.API/Models/Flashcards/FlashcardModels.cs index a4fcfaa..8d641f6 100644 --- a/Mindforge.API/Models/Flashcards/FlashcardModels.cs +++ b/Mindforge.API/Models/Flashcards/FlashcardModels.cs @@ -19,11 +19,69 @@ namespace Mindforge.API.Models.Flashcards public string Front { get; set; } = string.Empty; public string Back { get; set; } = string.Empty; public int Position { get; set; } + public int CorrectCount { get; set; } + public int IncorrectCount { get; set; } public DateTime CreatedAt { get; set; } + public DateTime? LastReviewedAt { get; set; } } public class FlashcardLibraryDetails : FlashcardLibrarySummary { public List Cards { get; set; } = []; } + + public class FlashcardCardWithLibrary : FlashcardCard + { + public string FilePath { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + } + + public class FlashcardRagDashboard + { + public DateTime GeneratedAt { get; set; } + public List Subjects { get; set; } = []; + } + + public class FlashcardRagSubjectGroup + { + public string Subject { get; set; } = string.Empty; + public FlashcardRagSummary Summary { get; set; } = new(); + public List SubSubjects { get; set; } = []; + } + + public class FlashcardRagSubSubjectGroup + { + public string SubSubject { get; set; } = string.Empty; + public FlashcardRagSummary Summary { get; set; } = new(); + public List Cards { get; set; } = []; + } + + public class FlashcardRagSummary + { + public int GreenCount { get; set; } + public int AmberCount { get; set; } + public int RedCount { get; set; } + public int GreyCount { get; set; } + public int ActiveCount { get; set; } + public double GreenPercentage { get; set; } + public double AttentionPercentage { get; set; } + } + + public class FlashcardRagCard + { + public long CardId { get; set; } + public long LibraryId { get; set; } + public string FileName { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string SubSubject { get; set; } = string.Empty; + public string Front { get; set; } = string.Empty; + public string Back { get; set; } = string.Empty; + public int CorrectCount { get; set; } + public int IncorrectCount { get; set; } + public int TotalAnswers { get; set; } + public double PerformanceRate { get; set; } + public DateTime? LastReviewedAt { get; set; } + public string RagStatus { get; set; } = "Grey"; + } } diff --git a/Mindforge.API/Models/Requests/FlashcardReviewAnswerRequest.cs b/Mindforge.API/Models/Requests/FlashcardReviewAnswerRequest.cs new file mode 100644 index 0000000..a9dec7b --- /dev/null +++ b/Mindforge.API/Models/Requests/FlashcardReviewAnswerRequest.cs @@ -0,0 +1,8 @@ +namespace Mindforge.API.Models.Requests +{ + public class FlashcardReviewAnswerRequest + { + public long CardId { get; set; } + public bool Correct { get; set; } + } +} diff --git a/Mindforge.API/Repositories/FlashcardRepository.cs b/Mindforge.API/Repositories/FlashcardRepository.cs index b748463..61b14d5 100644 --- a/Mindforge.API/Repositories/FlashcardRepository.cs +++ b/Mindforge.API/Repositories/FlashcardRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using Mindforge.API.Exceptions; using Mindforge.API.Models.Flashcards; using Mindforge.API.Services.Interfaces; using Npgsql; @@ -37,11 +38,24 @@ namespace Mindforge.API.Repositories front TEXT NOT NULL, back TEXT NOT NULL, position INTEGER NOT NULL, + correct_count INTEGER NOT NULL DEFAULT 0, + incorrect_count INTEGER NOT NULL DEFAULT 0, + last_reviewed_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + ALTER TABLE flashcards + ADD COLUMN IF NOT EXISTS correct_count INTEGER NOT NULL DEFAULT 0; + + ALTER TABLE flashcards + ADD COLUMN IF NOT EXISTS incorrect_count INTEGER NOT NULL DEFAULT 0; + + ALTER TABLE flashcards + ADD COLUMN IF NOT EXISTS last_reviewed_at TIMESTAMPTZ NULL; + CREATE INDEX IF NOT EXISTS ix_flashcard_libraries_subject ON flashcard_libraries(subject); CREATE INDEX IF NOT EXISTS ix_flashcards_library_id_position ON flashcards(library_id, position); + CREATE INDEX IF NOT EXISTS ix_flashcards_last_reviewed_at ON flashcards(last_reviewed_at); """; await using var connection = CreateConnection(); @@ -193,6 +207,9 @@ namespace Mindforge.API.Repositories front AS Front, back AS Back, position AS Position, + correct_count AS CorrectCount, + incorrect_count AS IncorrectCount, + last_reviewed_at AS LastReviewedAt, created_at AS CreatedAt FROM flashcards WHERE library_id = @LibraryId @@ -240,6 +257,9 @@ namespace Mindforge.API.Repositories front AS Front, back AS Back, position AS Position, + correct_count AS CorrectCount, + incorrect_count AS IncorrectCount, + last_reviewed_at AS LastReviewedAt, created_at AS CreatedAt FROM flashcards WHERE library_id = ANY(@LibraryIds) @@ -252,6 +272,49 @@ namespace Mindforge.API.Repositories return cards.ToList(); } + public async Task RecordReviewAnswerAsync(long cardId, bool correct) + { + var sql = correct + ? "UPDATE flashcards SET correct_count = correct_count + 1, last_reviewed_at = NOW() WHERE id = @CardId;" + : "UPDATE flashcards SET incorrect_count = incorrect_count + 1, last_reviewed_at = NOW() WHERE id = @CardId;"; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + + var affectedRows = await connection.ExecuteAsync(sql, new { CardId = cardId }); + if (affectedRows == 0) + { + throw new UserException("Card de revisao nao encontrado para registrar resposta."); + } + } + + public async Task> GetAllCardsWithLibraryAsync() + { + const string sql = """ + SELECT + f.id AS Id, + f.library_id AS LibraryId, + f.front AS Front, + f.back AS Back, + f.position AS Position, + f.correct_count AS CorrectCount, + f.incorrect_count AS IncorrectCount, + f.last_reviewed_at AS LastReviewedAt, + f.created_at AS CreatedAt, + l.file_path AS FilePath, + l.file_name AS FileName, + l.subject AS Subject + FROM flashcards f + INNER JOIN flashcard_libraries l ON l.id = f.library_id + ORDER BY l.subject, l.file_name, f.position; + """; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + var cards = await connection.QueryAsync(sql); + return cards.ToList(); + } + private NpgsqlConnection CreateConnection() { return new NpgsqlConnection(_connectionString); diff --git a/Mindforge.API/Services/FlashcardService.cs b/Mindforge.API/Services/FlashcardService.cs index dab3419..e3834cb 100644 --- a/Mindforge.API/Services/FlashcardService.cs +++ b/Mindforge.API/Services/FlashcardService.cs @@ -98,6 +98,61 @@ namespace Mindforge.API.Services return await _flashcardRepository.GetCardsForLibrariesAsync(libraryIds); } + public async Task GetRagStatusAsync() + { + var now = DateTime.UtcNow; + var cards = await _flashcardRepository.GetAllCardsWithLibraryAsync(); + + var ragCards = cards + .Select(card => BuildRagCard(card, now)) + .ToList(); + + var subjectGroups = ragCards + .GroupBy(card => card.Subject, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(subjectGroup => + { + var subSubjectGroups = subjectGroup + .GroupBy(card => card.SubSubject, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(subSubjectGroup => + { + var subSubjectCards = subSubjectGroup.ToList(); + return new FlashcardRagSubSubjectGroup + { + SubSubject = subSubjectGroup.Key, + Summary = BuildSummary(subSubjectCards), + Cards = subSubjectCards + }; + }) + .ToList(); + + return new FlashcardRagSubjectGroup + { + Subject = subjectGroup.Key, + Summary = BuildSummary(subjectGroup), + SubSubjects = subSubjectGroups + }; + }) + .ToList(); + + return new FlashcardRagDashboard + { + GeneratedAt = now, + Subjects = subjectGroups + }; + } + + public async Task RecordReviewAnswerAsync(long cardId, bool correct) + { + if (cardId <= 0) + { + throw new UserException("Card de revisao invalido."); + } + + await _flashcardRepository.RecordReviewAnswerAsync(cardId, correct); + } + private async Task> GenerateCardsForFileAsync( string filePath, string fileContent, @@ -228,6 +283,120 @@ namespace Mindforge.API.Services return segments[0]; } + private static string ExtractSubSubject(string filePath) + { + var normalized = filePath.Replace('\\', '/'); + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length <= 2) + { + return "Geral"; + } + + var concursosIndex = Array.FindIndex( + segments, + segment => segment.Equals("concursos", StringComparison.OrdinalIgnoreCase) + || segment.Equals("concurso", StringComparison.OrdinalIgnoreCase)); + + var subjectIndex = concursosIndex >= 0 && concursosIndex + 1 < segments.Length + ? concursosIndex + 1 + : 0; + + var subSubjectStart = subjectIndex + 1; + var subSubjectEnd = segments.Length - 1; + + if (subSubjectStart >= subSubjectEnd) + { + return "Geral"; + } + + return string.Join(" / ", segments[subSubjectStart..subSubjectEnd]); + } + + private static FlashcardRagCard BuildRagCard(FlashcardCardWithLibrary card, DateTime referenceTime) + { + var subject = string.IsNullOrWhiteSpace(card.Subject) + ? ExtractSubject(card.FilePath) + : card.Subject; + var subSubject = ExtractSubSubject(card.FilePath); + var totalAnswers = card.CorrectCount + card.IncorrectCount; + var performanceRate = totalAnswers == 0 + ? 0 + : (double)card.CorrectCount / totalAnswers; + + return new FlashcardRagCard + { + CardId = card.Id, + LibraryId = card.LibraryId, + FileName = card.FileName, + Subject = subject, + SubSubject = subSubject, + Front = card.Front, + Back = card.Back, + CorrectCount = card.CorrectCount, + IncorrectCount = card.IncorrectCount, + TotalAnswers = totalAnswers, + PerformanceRate = performanceRate, + LastReviewedAt = card.LastReviewedAt, + RagStatus = DetermineRagStatus(card.LastReviewedAt, performanceRate, referenceTime) + }; + } + + private static string DetermineRagStatus(DateTime? lastReviewedAt, double performanceRate, DateTime referenceTime) + { + if (!lastReviewedAt.HasValue) + { + return "Grey"; + } + + var lastReviewUtc = lastReviewedAt.Value.ToUniversalTime(); + var elapsedDays = (referenceTime.Date - lastReviewUtc.Date).TotalDays; + + if (elapsedDays >= 40 || performanceRate < 0.4) + { + return "Red"; + } + + if (elapsedDays >= 30 || performanceRate <= 0.6) + { + return "Amber"; + } + + if (elapsedDays < 30 && performanceRate > 0.6) + { + return "Green"; + } + + return "Amber"; + } + + private static FlashcardRagSummary BuildSummary(IEnumerable cards) + { + var cardList = cards.ToList(); + var greenCount = cardList.Count(card => card.RagStatus == "Green"); + var amberCount = cardList.Count(card => card.RagStatus == "Amber"); + var redCount = cardList.Count(card => card.RagStatus == "Red"); + var greyCount = cardList.Count(card => card.RagStatus == "Grey"); + var activeCount = greenCount + amberCount + redCount; + + var greenPercentage = activeCount == 0 + ? 0 + : Math.Round((double)greenCount / activeCount * 100, 2); + var attentionPercentage = activeCount == 0 + ? 0 + : Math.Round((double)(amberCount + redCount) / activeCount * 100, 2); + + return new FlashcardRagSummary + { + GreenCount = greenCount, + AmberCount = amberCount, + RedCount = redCount, + GreyCount = greyCount, + ActiveCount = activeCount, + GreenPercentage = greenPercentage, + AttentionPercentage = attentionPercentage + }; + } + private class FlashcardJsonPayload { public List Flashcards { get; set; } = []; diff --git a/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs b/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs index c549e86..5f2a470 100644 --- a/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs +++ b/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs @@ -14,5 +14,7 @@ namespace Mindforge.API.Services.Interfaces Task> GetLibrariesAsync(); Task GetLibraryByIdAsync(long libraryId); Task> GetCardsForLibrariesAsync(IReadOnlyList libraryIds); + Task> GetAllCardsWithLibraryAsync(); + Task RecordReviewAnswerAsync(long cardId, bool correct); } } diff --git a/Mindforge.API/Services/Interfaces/IFlashcardService.cs b/Mindforge.API/Services/Interfaces/IFlashcardService.cs index f559555..feb38f8 100644 --- a/Mindforge.API/Services/Interfaces/IFlashcardService.cs +++ b/Mindforge.API/Services/Interfaces/IFlashcardService.cs @@ -9,5 +9,7 @@ namespace Mindforge.API.Services.Interfaces Task> GetLibrariesAsync(); Task GetLibraryByIdAsync(long libraryId); Task> BuildReviewSessionAsync(FlashcardReviewSessionRequest request); + Task GetRagStatusAsync(); + Task RecordReviewAnswerAsync(long cardId, bool correct); } } diff --git a/Mindforge.Web/src/app.tsx b/Mindforge.Web/src/app.tsx index 52fdb99..70fa84c 100644 --- a/Mindforge.Web/src/app.tsx +++ b/Mindforge.Web/src/app.tsx @@ -5,9 +5,10 @@ import { Sidebar } from './components/Sidebar'; import { VerificadorComponent } from './components/VerificadorComponent'; import { FlashcardComponent } from './components/FlashcardComponent'; import { FlashcardReviewComponent } from './components/FlashcardReviewComponent'; +import { SpacedReviewComponent } from './components/SpacedReviewComponent'; export function App() { - const [activeModule, setActiveModule] = useState<'home' | 'verificador' | 'flashcards' | 'revisao-flashcards'>('home'); + const [activeModule, setActiveModule] = useState<'home' | 'verificador' | 'flashcards' | 'revisao-flashcards' | 'revisao-espacada'>('home'); return ( <> @@ -31,6 +32,9 @@ export function App() {
+
+ +
diff --git a/Mindforge.Web/src/components/FlashcardReviewComponent.tsx b/Mindforge.Web/src/components/FlashcardReviewComponent.tsx index bf5383f..df6a49d 100644 --- a/Mindforge.Web/src/components/FlashcardReviewComponent.tsx +++ b/Mindforge.Web/src/components/FlashcardReviewComponent.tsx @@ -24,6 +24,17 @@ function difficultyLabel(difficulty: string) { return difficulty === 'Hard' ? 'Dificil' : 'Facil'; } +function shuffleCards(cards: FlashcardCard[]) { + const shuffled = [...cards]; + + for (let i = shuffled.length - 1; i > 0; i--) { + const randomIndex = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[i]]; + } + + return shuffled; +} + export function FlashcardReviewComponent() { const [libraries, setLibraries] = useState([]); const [selectedLibraryIds, setSelectedLibraryIds] = useState([]); @@ -33,6 +44,7 @@ export function FlashcardReviewComponent() { const [sessionCards, setSessionCards] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [showAnswer, setShowAnswer] = useState(false); + const [submittingAnswer, setSubmittingAnswer] = useState(false); useEffect(() => { let cancelled = false; @@ -97,7 +109,7 @@ export function FlashcardReviewComponent() { libraryIds: selectedLibraryIds, }); - setSessionCards(response.cards); + setSessionCards(shuffleCards(response.cards)); setCurrentIndex(0); setShowAnswer(false); } catch (err: any) { @@ -111,6 +123,7 @@ export function FlashcardReviewComponent() { setSessionCards([]); setCurrentIndex(0); setShowAnswer(false); + setSubmittingAnswer(false); }; const goToPrevious = () => { @@ -121,12 +134,32 @@ export function FlashcardReviewComponent() { setShowAnswer(false); }; - const goToNext = () => { - if (currentIndex >= sessionCards.length - 1) { + const registerReviewAnswer = async (correct: boolean) => { + if (!currentCard) { return; } - setCurrentIndex(currentIndex + 1); - setShowAnswer(false); + + setSubmittingAnswer(true); + setError(null); + + try { + await MindforgeApiService.recordFlashcardReviewAnswer({ + cardId: currentCard.id, + correct, + }); + + if (currentIndex >= sessionCards.length - 1) { + endSession(); + return; + } + + setCurrentIndex(currentIndex + 1); + setShowAnswer(false); + } catch (err: any) { + setError(err?.message || 'Falha ao registrar resposta da revisao.'); + } finally { + setSubmittingAnswer(false); + } }; return ( @@ -206,7 +239,7 @@ export function FlashcardReviewComponent() {
- @@ -217,9 +250,14 @@ export function FlashcardReviewComponent() { )} {showAnswer && ( - + <> + + + )} +
); diff --git a/Mindforge.Web/src/components/SpacedReviewComponent.css b/Mindforge.Web/src/components/SpacedReviewComponent.css new file mode 100644 index 0000000..49fefa6 --- /dev/null +++ b/Mindforge.Web/src/components/SpacedReviewComponent.css @@ -0,0 +1,223 @@ +.spaced-review-container { + width: 100%; + max-width: 980px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.2rem; + animation: slideUp 0.45s ease-out; +} + +.spaced-review-title { + font-size: 2.35rem; +} + +.spaced-review-error { + color: #ff9c96; + text-align: left; + background: rgba(255, 69, 58, 0.12); + border: 1px solid rgba(255, 69, 58, 0.4); + border-radius: 10px; + padding: 0.8rem 1rem; +} + +.spaced-review-panel, +.spaced-review-session-panel { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + padding: 1.25rem; + text-align: left; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.18); +} + +.spaced-review-state { + color: rgba(255, 255, 255, 0.72); + font-size: 0.95rem; +} + +.spaced-review-filters { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + margin-bottom: 1rem; +} + +.spaced-review-filter { + display: flex; + align-items: center; + gap: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + padding: 0.35rem 0.7rem; + background: rgba(255, 255, 255, 0.05); + font-size: 0.88rem; +} + +.spaced-review-filter input[type="checkbox"], +.spaced-review-subsubject-item input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--color-accent); +} + +.spaced-review-subjects { + display: grid; + gap: 1rem; +} + +.spaced-review-subject { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(0, 0, 0, 0.18); + padding: 0.9rem; +} + +.spaced-review-subject-header h3 { + margin: 0; + font-size: 1.03rem; +} + +.spaced-review-subject-header p { + margin: 0.35rem 0 0; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.9); +} + +.spaced-review-subject-header small { + display: block; + margin-top: 0.35rem; + color: rgba(255, 255, 255, 0.67); + font-size: 0.79rem; +} + +.spaced-review-subsubject-list { + display: grid; + gap: 0.6rem; + margin-top: 0.85rem; +} + +.spaced-review-subsubject-item { + display: flex; + align-items: flex-start; + gap: 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.6rem 0.7rem; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; +} + +.spaced-review-subsubject-texts { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.spaced-review-subsubject-texts strong { + font-size: 0.93rem; +} + +.spaced-review-subsubject-texts span { + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.78); +} + +.spaced-review-subsubject-texts small { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.6); +} + +.spaced-review-footer { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.spaced-review-footer p { + font-size: 0.93rem; + color: rgba(255, 255, 255, 0.78); +} + +.spaced-review-progress { + display: flex; + align-items: center; + gap: 0.9rem; + margin-bottom: 0.8rem; +} + +.spaced-review-progress span { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + min-width: 60px; +} + +.spaced-review-progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.spaced-review-progress-fill { + height: 100%; + background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.8), rgba(var(--color-accent-rgb), 1)); + border-radius: 999px; + transition: width 0.25s ease; +} + +.spaced-review-card { + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 10px; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + min-height: 260px; +} + +.spaced-review-card header { + margin-bottom: 0.8rem; +} + +.spaced-review-card small { + color: rgba(255, 255, 255, 0.62); +} + +.spaced-review-card h3 { + margin: 0 0 0.4rem; + font-size: 0.98rem; + color: rgba(255, 255, 255, 0.9); +} + +.spaced-review-card p { + margin: 0 0 1rem; + font-size: 1rem; + line-height: 1.55; +} + +.spaced-review-card-meta { + display: flex; + gap: 1rem; + flex-wrap: wrap; + color: rgba(255, 255, 255, 0.75); + font-size: 0.84rem; +} + +.spaced-review-session-actions { + margin-top: 1rem; + display: flex; + gap: 0.65rem; + flex-wrap: wrap; +} + +@media (max-width: 760px) { + .spaced-review-session-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/Mindforge.Web/src/components/SpacedReviewComponent.tsx b/Mindforge.Web/src/components/SpacedReviewComponent.tsx new file mode 100644 index 0000000..c4387eb --- /dev/null +++ b/Mindforge.Web/src/components/SpacedReviewComponent.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { + MindforgeApiService, + type FlashcardCard, + type FlashcardRagCard, + type FlashcardRagDashboardResponse, + type FlashcardRagStatus, +} from '../services/MindforgeApiService'; +import { Button } from './Button'; +import './SpacedReviewComponent.css'; + +interface RagStatusOption { + status: FlashcardRagStatus; + label: string; +} + +const STATUS_OPTIONS: RagStatusOption[] = [ + { status: 'Red', label: 'Vermelho' }, + { status: 'Amber', label: 'Amarelo' }, + { status: 'Green', label: 'Verde' }, + { status: 'Grey', label: 'Cinza' }, +]; + +const STATUS_PRIORITY: Record = { + Red: 0, + Amber: 1, + Green: 2, + Grey: 3, +}; + +function buildGroupKey(subject: string, subSubject: string) { + return `${subject}::${subSubject}`; +} + +function shuffleCards(cards: FlashcardCard[]) { + const shuffled = [...cards]; + + for (let i = shuffled.length - 1; i > 0; i--) { + const randomIndex = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[i]]; + } + + return shuffled; +} + +function formatPercentage(value: number) { + return `${value.toFixed(2).replace('.', ',')}%`; +} + +function summaryText(activeCount: number, greenPercentage: number, attentionPercentage: number) { + if (activeCount === 0) { + return 'Sem revisoes avaliaveis'; + } + + return `Verde ${formatPercentage(greenPercentage)} | Atencao ${formatPercentage(attentionPercentage)}`; +} + +function formatPerformance(rate: number) { + return `${Math.round(rate * 100)}%`; +} + +function orderCardsForSession(cards: FlashcardCard[], ragByCardId: Map) { + const buckets: FlashcardCard[][] = [[], [], [], []]; + + cards.forEach((card) => { + const ragCard = ragByCardId.get(card.id); + const status = ragCard?.ragStatus || 'Grey'; + buckets[STATUS_PRIORITY[status]].push(card); + }); + + return buckets.flatMap((bucket) => shuffleCards(bucket)); +} + +export function SpacedReviewComponent() { + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState(['Red', 'Amber']); + const [selectedGroupKeys, setSelectedGroupKeys] = useState([]); + const [startingSession, setStartingSession] = useState(false); + const [sessionCards, setSessionCards] = useState([]); + const [sessionSourceCards, setSessionSourceCards] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [showAnswer, setShowAnswer] = useState(false); + const [submittingAnswer, setSubmittingAnswer] = useState(false); + + const loadDashboard = async (preserveSelection: boolean) => { + setLoading(true); + setError(null); + + try { + const response = await MindforgeApiService.getFlashcardRagStatus(); + setDashboard(response); + + const allGroupKeys = response.subjects.flatMap((subjectGroup) => + subjectGroup.subSubjects.map((subSubjectGroup) => + buildGroupKey(subjectGroup.subject, subSubjectGroup.subSubject)), + ); + + setSelectedGroupKeys((current) => { + if (!preserveSelection || current.length === 0) { + return allGroupKeys; + } + + const available = new Set(allGroupKeys); + const kept = current.filter((key) => available.has(key)); + return kept.length > 0 ? kept : allGroupKeys; + }); + } catch (err: any) { + setError(err?.message || 'Falha ao carregar status de revisao espacada.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + let cancelled = false; + + async function runLoad() { + if (cancelled) { + return; + } + await loadDashboard(false); + } + + runLoad(); + return () => { + cancelled = true; + }; + }, []); + + const ragCards = useMemo(() => { + if (!dashboard) { + return []; + } + + return dashboard.subjects.flatMap((subjectGroup) => + subjectGroup.subSubjects.flatMap((subSubjectGroup) => subSubjectGroup.cards)); + }, [dashboard]); + + const selectedRagCards = useMemo(() => { + const selectedGroups = new Set(selectedGroupKeys); + const selectedStatusSet = new Set(selectedStatuses); + + return ragCards.filter((card) => + selectedGroups.has(buildGroupKey(card.subject, card.subSubject)) + && selectedStatusSet.has(card.ragStatus)); + }, [ragCards, selectedGroupKeys, selectedStatuses]); + + const sessionSourceById = useMemo(() => { + return new Map(sessionSourceCards.map((card) => [card.cardId, card])); + }, [sessionSourceCards]); + + const currentCard = sessionCards[currentIndex]; + const currentCardMetadata = currentCard ? sessionSourceById.get(currentCard.id) : undefined; + + const progressPercent = sessionCards.length > 0 + ? ((currentIndex + 1) / sessionCards.length) * 100 + : 0; + + const toggleStatus = (status: FlashcardRagStatus) => { + if (selectedStatuses.includes(status)) { + setSelectedStatuses(selectedStatuses.filter((value) => value !== status)); + return; + } + + setSelectedStatuses([...selectedStatuses, status]); + }; + + const toggleGroup = (groupKey: string) => { + if (selectedGroupKeys.includes(groupKey)) { + setSelectedGroupKeys(selectedGroupKeys.filter((key) => key !== groupKey)); + return; + } + + setSelectedGroupKeys([...selectedGroupKeys, groupKey]); + }; + + const startSession = async () => { + if (selectedStatuses.length === 0) { + setError('Selecione ao menos um status para iniciar a revisao.'); + return; + } + + if (selectedGroupKeys.length === 0) { + setError('Selecione ao menos uma submateria para iniciar a revisao.'); + return; + } + + if (selectedRagCards.length === 0) { + setError('Nenhum card encontrado com os filtros selecionados.'); + return; + } + + setStartingSession(true); + setError(null); + + try { + const ragByCardId = new Map(selectedRagCards.map((card) => [card.cardId, card])); + const libraryIds = Array.from(new Set(selectedRagCards.map((card) => card.libraryId))); + const response = await MindforgeApiService.createFlashcardReviewSession({ libraryIds }); + const selectedCardIds = new Set(selectedRagCards.map((card) => card.cardId)); + const filteredCards = response.cards.filter((card) => selectedCardIds.has(card.id)); + const orderedCards = orderCardsForSession(filteredCards, ragByCardId); + + if (orderedCards.length === 0) { + setError('Os filtros selecionados nao retornaram cards para revisar.'); + return; + } + + setSessionSourceCards(selectedRagCards); + setSessionCards(orderedCards); + setCurrentIndex(0); + setShowAnswer(false); + } catch (err: any) { + setError(err?.message || 'Falha ao iniciar revisao espacada.'); + } finally { + setStartingSession(false); + } + }; + + const endSession = () => { + setSessionCards([]); + setSessionSourceCards([]); + setCurrentIndex(0); + setShowAnswer(false); + setSubmittingAnswer(false); + void loadDashboard(true); + }; + + const goToPrevious = () => { + if (currentIndex === 0) { + return; + } + setCurrentIndex(currentIndex - 1); + setShowAnswer(false); + }; + + const registerAnswer = async (correct: boolean) => { + if (!currentCard) { + return; + } + + setSubmittingAnswer(true); + setError(null); + + try { + await MindforgeApiService.recordFlashcardReviewAnswer({ + cardId: currentCard.id, + correct, + }); + + if (currentIndex >= sessionCards.length - 1) { + endSession(); + return; + } + + setCurrentIndex(currentIndex + 1); + setShowAnswer(false); + } catch (err: any) { + setError(err?.message || 'Falha ao registrar resposta da revisao.'); + } finally { + setSubmittingAnswer(false); + } + }; + + return ( +
+

Revisao espacada

+

Acompanhe o status RAG dos cards por materia e submateria.

+ + {error &&
{error}
} + + {sessionCards.length === 0 && ( +
+ {loading &&

Carregando painel de revisao...

} + {!loading && (!dashboard || dashboard.subjects.length === 0) && ( +

Nenhum flashcard encontrado para revisar.

+ )} + + {!loading && dashboard && dashboard.subjects.length > 0 && ( + <> +
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+ +
+ {dashboard.subjects.map((subjectGroup) => ( +
+
+

{subjectGroup.subject}

+

{summaryText( + subjectGroup.summary.activeCount, + subjectGroup.summary.greenPercentage, + subjectGroup.summary.attentionPercentage, + )}

+ + Verde: {subjectGroup.summary.greenCount} | Amarelo: {subjectGroup.summary.amberCount} + {' '} + | Vermelho: {subjectGroup.summary.redCount} | Cinza: {subjectGroup.summary.greyCount} + +
+ +
+ {subjectGroup.subSubjects.map((subSubjectGroup) => { + const groupKey = buildGroupKey(subjectGroup.subject, subSubjectGroup.subSubject); + const checked = selectedGroupKeys.includes(groupKey); + + return ( + + ); + })} +
+
+ ))} +
+ + )} + +
+

+ Cards selecionados: {selectedRagCards.length} +

+ +
+
+ )} + + {sessionCards.length > 0 && currentCard && ( +
+
+ {currentIndex + 1} / {sessionCards.length} +
+
+
+
+ +
+
+ + {currentCardMetadata?.fileName || 'Arquivo'} - {currentCardMetadata?.subject || 'Geral'} - {currentCardMetadata?.subSubject || 'Geral'} + +
+ +

Frente

+

{currentCard.front}

+ + {showAnswer && ( + <> +

Verso

+

{currentCard.back}

+
+ Desempenho: {currentCardMetadata ? formatPerformance(currentCardMetadata.performanceRate) : '-'} + Status: {currentCardMetadata?.ragStatus || 'Grey'} +
+ + )} +
+ +
+ + + {!showAnswer && ( + + )} + + {showAnswer && ( + <> + + + + )} + + +
+
+ )} +
+ ); +} diff --git a/Mindforge.Web/src/services/MindforgeApiService.ts b/Mindforge.Web/src/services/MindforgeApiService.ts index a2663e2..fc9b451 100644 --- a/Mindforge.Web/src/services/MindforgeApiService.ts +++ b/Mindforge.Web/src/services/MindforgeApiService.ts @@ -40,7 +40,10 @@ export interface FlashcardCard { front: string; back: string; position: number; + correctCount: number; + incorrectCount: number; createdAt: string; + lastReviewedAt?: string | null; } export interface FlashcardLibrarySummary { @@ -70,6 +73,56 @@ export interface FlashcardReviewSessionResponse { cards: FlashcardCard[]; } +export interface FlashcardReviewAnswerRequest { + cardId: number; + correct: boolean; +} + +export type FlashcardRagStatus = 'Grey' | 'Red' | 'Amber' | 'Green'; + +export interface FlashcardRagCard { + cardId: number; + libraryId: number; + fileName: string; + subject: string; + subSubject: string; + front: string; + back: string; + correctCount: number; + incorrectCount: number; + totalAnswers: number; + performanceRate: number; + lastReviewedAt?: string | null; + ragStatus: FlashcardRagStatus; +} + +export interface FlashcardRagSummary { + greenCount: number; + amberCount: number; + redCount: number; + greyCount: number; + activeCount: number; + greenPercentage: number; + attentionPercentage: number; +} + +export interface FlashcardRagSubSubjectGroup { + subSubject: string; + summary: FlashcardRagSummary; + cards: FlashcardRagCard[]; +} + +export interface FlashcardRagSubjectGroup { + subject: string; + summary: FlashcardRagSummary; + subSubjects: FlashcardRagSubSubjectGroup[]; +} + +export interface FlashcardRagDashboardResponse { + generatedAt: string; + subjects: FlashcardRagSubjectGroup[]; +} + async function throwIfNotOk(response: Response, fallback: string) { if (response.ok) { return; @@ -140,6 +193,24 @@ export const MindforgeApiService = { return response.json(); }, + async recordFlashcardReviewAnswer(data: FlashcardReviewAnswerRequest): Promise { + const response = await fetch(`${BASE_URL}/api/v1/flashcard/review-answer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + await throwIfNotOk(response, `Erro ao registrar resposta da revisao: ${response.statusText}`); + }, + + async getFlashcardRagStatus(): Promise { + const response = await fetch(`${BASE_URL}/api/v1/flashcard/rag-status`); + await throwIfNotOk(response, `Erro ao buscar status RAG de revisao: ${response.statusText}`); + return response.json(); + }, + async getRepositoryInfo(): Promise { const response = await fetch(`${BASE_URL}/api/v1/repository/info`); await throwIfNotOk(response, `Erro ao buscar info do repositorio: ${response.statusText}`); diff --git a/project-context.md b/project-context.md index a252c2c..f3883c5 100644 --- a/project-context.md +++ b/project-context.md @@ -337,7 +337,7 @@ Exemplos: ### Tabelas de Flashcard - `flashcard_libraries`: `id`, `file_path` (unico), `file_name`, `subject`, `difficulty`, `card_count`, `created_at`, `updated_at`. -- `flashcards`: `id`, `library_id`, `front`, `back`, `position`, `created_at`. +- `flashcards`: `id`, `library_id`, `front`, `back`, `position`, `correct_count`, `incorrect_count`, `created_at`. ### API de Flashcards (v1) - `POST /api/v1/flashcard/generate` @@ -350,6 +350,9 @@ Exemplos: - `POST /api/v1/flashcard/review-session` Request: `{ libraryIds: number[] }` Retorna os cards combinados para sessao de revisao (sem repeticao espaciada nesta fase). +- `POST /api/v1/flashcard/review-answer` + Request: `{ cardId: number, correct: boolean }` + Registra no banco o resultado da revisao (`Acertei`/`Errei`) por card. ### Frontend - Modulo `Flashcards` atualizado para: @@ -360,9 +363,48 @@ Exemplos: - Novo modulo `Revisao Flashcards`: - lista bibliotecas agrupadas por materia (`subject`); - permite selecionar multiplas bibliotecas; - - inicia sessao estilo Anki simplificada (frente, revelar verso, anterior/proximo, progresso visual). + - embaralha aleatoriamente os cards ao iniciar a sessao de revisao; + - inicia sessao estilo Anki simplificada (frente, revelar verso, acertei/errei, anterior, progresso visual). + - ao gerar novamente uma biblioteca, os contadores `correct_count` e `incorrect_count` dos cards sao resetados para zero. ### Novas Configuracoes - `ConnectionStrings:MindforgeDb` para conexao PostgreSQL. - Fallback local/default: `Host=localhost;Port=3307;Database=mindforge;Username=root;Password=root`. + +--- + +## Atualizacao - Revisao Espacada RAG (2026-06-01) + +### Mudancas de Arquitetura +- O dominio de flashcards agora guarda `last_reviewed_at` por card para permitir classificacao temporal. +- O status RAG e calculado em tempo de leitura (nao e persistido), para evitar status defasado. +- O agrupamento de dashboard usa `subject` + `subSubject`, onde `subSubject` vem dos segmentos do caminho apos a materia e antes do arquivo. + +### Regras RAG por Card +- `Grey`: card nunca revisado (`last_reviewed_at` nulo). +- `Red`: revisado ha 40 dias ou mais **ou** desempenho `< 40%`. +- `Amber`: revisado ha 30 dias ou mais **ou** desempenho `<= 60%`. +- `Green`: revisado ha menos de 30 dias **e** desempenho `> 60%`. +- Desempenho: `correct_count / (correct_count + incorrect_count)`. + +### Banco e Repositorio +- Tabela `flashcards` recebeu coluna `last_reviewed_at TIMESTAMPTZ NULL` (migracao idempotente no startup). +- `POST /api/v1/flashcard/review-answer` agora atualiza `correct_count`/`incorrect_count` e define `last_reviewed_at = NOW()`. + +### API de Flashcards (v1) +- Novo endpoint `GET /api/v1/flashcard/rag-status`: + - Retorna dashboard de revisao espacada com grupos por materia/submateria. + - Inclui cards com `ragStatus`, `performanceRate`, `totalAnswers` e `lastReviewedAt`. + - Inclui sumarios por materia/submateria com percentuais: + - Verde = `green / (green + amber + red)` + - Atencao = `(amber + red) / (green + amber + red)` + - Cards cinza ficam fora do denominador. + +### Frontend +- Novo modulo `Revisao Espacada` abaixo de `Revisao Flashcards` na sidebar. +- O painel mostra: + - status agregados por materia e submateria; + - filtros por status RAG (Vermelho, Amarelo, Verde, Cinza); + - total de cards selecionados para revisao. +- A sessao de revisao espacada reutiliza o fluxo de resposta (`Acertei`/`Errei`) e prioriza cards por status (Red, Amber, Green, Grey).