timed revision
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 3m58s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 37s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 5m19s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 11s
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 3m58s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 37s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 5m19s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 11s
This commit is contained in:
@@ -36,6 +36,13 @@ namespace Mindforge.API.Controllers
|
||||
return Ok(libraries);
|
||||
}
|
||||
|
||||
[HttpGet("rag-status")]
|
||||
public async Task<IActionResult> GetRagStatus()
|
||||
{
|
||||
var dashboard = await _flashcardService.GetRagStatusAsync();
|
||||
return Ok(dashboard);
|
||||
}
|
||||
|
||||
[HttpGet("libraries/{id:long}")]
|
||||
public async Task<IActionResult> 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<IActionResult> RecordReviewAnswer([FromBody] FlashcardReviewAnswerRequest request)
|
||||
{
|
||||
request ??= new FlashcardReviewAnswerRequest();
|
||||
await _flashcardService.RecordReviewAnswerAsync(request.CardId, request.Correct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FlashcardCard> 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<FlashcardRagSubjectGroup> Subjects { get; set; } = [];
|
||||
}
|
||||
|
||||
public class FlashcardRagSubjectGroup
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public FlashcardRagSummary Summary { get; set; } = new();
|
||||
public List<FlashcardRagSubSubjectGroup> SubSubjects { get; set; } = [];
|
||||
}
|
||||
|
||||
public class FlashcardRagSubSubjectGroup
|
||||
{
|
||||
public string SubSubject { get; set; } = string.Empty;
|
||||
public FlashcardRagSummary Summary { get; set; } = new();
|
||||
public List<FlashcardRagCard> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Mindforge.API.Models.Requests
|
||||
{
|
||||
public class FlashcardReviewAnswerRequest
|
||||
{
|
||||
public long CardId { get; set; }
|
||||
public bool Correct { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<FlashcardCardWithLibrary>> 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<FlashcardCardWithLibrary>(sql);
|
||||
return cards.ToList();
|
||||
}
|
||||
|
||||
private NpgsqlConnection CreateConnection()
|
||||
{
|
||||
return new NpgsqlConnection(_connectionString);
|
||||
|
||||
@@ -98,6 +98,61 @@ namespace Mindforge.API.Services
|
||||
return await _flashcardRepository.GetCardsForLibrariesAsync(libraryIds);
|
||||
}
|
||||
|
||||
public async Task<FlashcardRagDashboard> 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<IReadOnlyList<FlashcardDraftCard>> 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<FlashcardRagCard> 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<FlashcardJsonCard> Flashcards { get; set; } = [];
|
||||
|
||||
@@ -14,5 +14,7 @@ namespace Mindforge.API.Services.Interfaces
|
||||
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
|
||||
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
|
||||
Task<IReadOnlyList<FlashcardCard>> GetCardsForLibrariesAsync(IReadOnlyList<long> libraryIds);
|
||||
Task<IReadOnlyList<FlashcardCardWithLibrary>> GetAllCardsWithLibraryAsync();
|
||||
Task RecordReviewAnswerAsync(long cardId, bool correct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ namespace Mindforge.API.Services.Interfaces
|
||||
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
|
||||
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
|
||||
Task<IReadOnlyList<FlashcardCard>> BuildReviewSessionAsync(FlashcardReviewSessionRequest request);
|
||||
Task<FlashcardRagDashboard> GetRagStatusAsync();
|
||||
Task RecordReviewAnswerAsync(long cardId, bool correct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div style={{ display: activeModule === 'revisao-flashcards' ? 'block' : 'none', height: '100%', width: '100%' }}>
|
||||
<FlashcardReviewComponent />
|
||||
</div>
|
||||
<div style={{ display: activeModule === 'revisao-espacada' ? 'block' : 'none', height: '100%', width: '100%' }}>
|
||||
<SpacedReviewComponent />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<FlashcardLibrarySummary[]>([]);
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState<number[]>([]);
|
||||
@@ -33,6 +44,7 @@ export function FlashcardReviewComponent() {
|
||||
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
|
||||
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() {
|
||||
</article>
|
||||
|
||||
<div className="review-session-actions">
|
||||
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0}>
|
||||
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0 || submittingAnswer}>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
@@ -217,9 +250,14 @@ export function FlashcardReviewComponent() {
|
||||
)}
|
||||
|
||||
{showAnswer && (
|
||||
<Button variant="primary" onClick={goToNext} disabled={currentIndex >= sessionCards.length - 1}>
|
||||
Proximo
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="primary" onClick={() => registerReviewAnswer(true)} disabled={submittingAnswer}>
|
||||
Acertei
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => registerReviewAnswer(false)} disabled={submittingAnswer}>
|
||||
Errei
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" onClick={endSession}>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Button } from './Button';
|
||||
import './Sidebar.css';
|
||||
|
||||
interface SidebarProps {
|
||||
onModuleChange: (module: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards') => void;
|
||||
activeModule: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards';
|
||||
onModuleChange: (module: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards' | 'revisao-espacada') => void;
|
||||
activeModule: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards' | 'revisao-espacada';
|
||||
}
|
||||
|
||||
export function Sidebar({ onModuleChange, activeModule }: SidebarProps) {
|
||||
@@ -34,6 +34,13 @@ export function Sidebar({ onModuleChange, activeModule }: SidebarProps) {
|
||||
>
|
||||
Revisao Flashcards
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeModule === 'revisao-espacada' ? 'primary' : 'secondary'}
|
||||
onClick={() => onModuleChange('revisao-espacada')}
|
||||
className="sidebar-btn"
|
||||
>
|
||||
Revisao Espacada
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
223
Mindforge.Web/src/components/SpacedReviewComponent.css
Normal file
223
Mindforge.Web/src/components/SpacedReviewComponent.css
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
422
Mindforge.Web/src/components/SpacedReviewComponent.tsx
Normal file
422
Mindforge.Web/src/components/SpacedReviewComponent.tsx
Normal file
@@ -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<FlashcardRagStatus, number> = {
|
||||
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<number, FlashcardRagCard>) {
|
||||
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<FlashcardRagDashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<FlashcardRagStatus[]>(['Red', 'Amber']);
|
||||
const [selectedGroupKeys, setSelectedGroupKeys] = useState<string[]>([]);
|
||||
const [startingSession, setStartingSession] = useState(false);
|
||||
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
|
||||
const [sessionSourceCards, setSessionSourceCards] = useState<FlashcardRagCard[]>([]);
|
||||
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 (
|
||||
<div className="spaced-review-container">
|
||||
<h2 className="title spaced-review-title">Revisao espacada</h2>
|
||||
<p className="subtitle">Acompanhe o status RAG dos cards por materia e submateria.</p>
|
||||
|
||||
{error && <div className="spaced-review-error">{error}</div>}
|
||||
|
||||
{sessionCards.length === 0 && (
|
||||
<div className="spaced-review-panel">
|
||||
{loading && <p className="spaced-review-state">Carregando painel de revisao...</p>}
|
||||
{!loading && (!dashboard || dashboard.subjects.length === 0) && (
|
||||
<p className="spaced-review-state">Nenhum flashcard encontrado para revisar.</p>
|
||||
)}
|
||||
|
||||
{!loading && dashboard && dashboard.subjects.length > 0 && (
|
||||
<>
|
||||
<div className="spaced-review-filters">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<label key={option.status} className="spaced-review-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStatuses.includes(option.status)}
|
||||
onChange={() => toggleStatus(option.status)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="spaced-review-subjects">
|
||||
{dashboard.subjects.map((subjectGroup) => (
|
||||
<section key={subjectGroup.subject} className="spaced-review-subject">
|
||||
<header className="spaced-review-subject-header">
|
||||
<h3>{subjectGroup.subject}</h3>
|
||||
<p>{summaryText(
|
||||
subjectGroup.summary.activeCount,
|
||||
subjectGroup.summary.greenPercentage,
|
||||
subjectGroup.summary.attentionPercentage,
|
||||
)}</p>
|
||||
<small>
|
||||
Verde: {subjectGroup.summary.greenCount} | Amarelo: {subjectGroup.summary.amberCount}
|
||||
{' '}
|
||||
| Vermelho: {subjectGroup.summary.redCount} | Cinza: {subjectGroup.summary.greyCount}
|
||||
</small>
|
||||
</header>
|
||||
|
||||
<div className="spaced-review-subsubject-list">
|
||||
{subjectGroup.subSubjects.map((subSubjectGroup) => {
|
||||
const groupKey = buildGroupKey(subjectGroup.subject, subSubjectGroup.subSubject);
|
||||
const checked = selectedGroupKeys.includes(groupKey);
|
||||
|
||||
return (
|
||||
<label key={groupKey} className="spaced-review-subsubject-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleGroup(groupKey)}
|
||||
/>
|
||||
<div className="spaced-review-subsubject-texts">
|
||||
<strong>{subSubjectGroup.subSubject}</strong>
|
||||
<span>{summaryText(
|
||||
subSubjectGroup.summary.activeCount,
|
||||
subSubjectGroup.summary.greenPercentage,
|
||||
subSubjectGroup.summary.attentionPercentage,
|
||||
)}</span>
|
||||
<small>
|
||||
Cards: {subSubjectGroup.cards.length} | Cinza: {subSubjectGroup.summary.greyCount}
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="spaced-review-footer">
|
||||
<p>
|
||||
Cards selecionados: <strong>{selectedRagCards.length}</strong>
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={startingSession || selectedRagCards.length === 0}
|
||||
onClick={startSession}
|
||||
>
|
||||
{startingSession ? 'Iniciando...' : 'Iniciar Revisao Espacada'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionCards.length > 0 && currentCard && (
|
||||
<div className="spaced-review-session-panel">
|
||||
<div className="spaced-review-progress">
|
||||
<span>{currentIndex + 1} / {sessionCards.length}</span>
|
||||
<div className="spaced-review-progress-bar">
|
||||
<div className="spaced-review-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="spaced-review-card">
|
||||
<header>
|
||||
<small>
|
||||
{currentCardMetadata?.fileName || 'Arquivo'} - {currentCardMetadata?.subject || 'Geral'} - {currentCardMetadata?.subSubject || 'Geral'}
|
||||
</small>
|
||||
</header>
|
||||
|
||||
<h3>Frente</h3>
|
||||
<p>{currentCard.front}</p>
|
||||
|
||||
{showAnswer && (
|
||||
<>
|
||||
<h3>Verso</h3>
|
||||
<p>{currentCard.back}</p>
|
||||
<div className="spaced-review-card-meta">
|
||||
<span>Desempenho: {currentCardMetadata ? formatPerformance(currentCardMetadata.performanceRate) : '-'}</span>
|
||||
<span>Status: {currentCardMetadata?.ragStatus || 'Grey'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<div className="spaced-review-session-actions">
|
||||
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0 || submittingAnswer}>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{!showAnswer && (
|
||||
<Button variant="primary" onClick={() => setShowAnswer(true)}>
|
||||
Revelar Resposta
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAnswer && (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => registerAnswer(true)} disabled={submittingAnswer}>
|
||||
Acertei
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => registerAnswer(false)} disabled={submittingAnswer}>
|
||||
Errei
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" onClick={endSession}>
|
||||
Encerrar Sessao
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<FlashcardRagDashboardResponse> {
|
||||
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<RepositoryInfo> {
|
||||
const response = await fetch(`${BASE_URL}/api/v1/repository/info`);
|
||||
await throwIfNotOk(response, `Erro ao buscar info do repositorio: ${response.statusText}`);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user