modifications
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 2m10s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 8s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 3m38s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 8s

This commit is contained in:
2026-06-01 19:29:01 -03:00
parent f03bcc40e3
commit 097ba577cf
6 changed files with 375 additions and 159 deletions

View File

@@ -54,7 +54,7 @@ namespace Mindforge.API.Models.Flashcards
{ {
public string SubSubject { get; set; } = string.Empty; public string SubSubject { get; set; } = string.Empty;
public FlashcardRagSummary Summary { get; set; } = new(); public FlashcardRagSummary Summary { get; set; } = new();
public List<FlashcardRagCard> Cards { get; set; } = []; public List<FlashcardRagLibrary> Libraries { get; set; } = [];
} }
public class FlashcardRagSummary public class FlashcardRagSummary
@@ -68,17 +68,16 @@ namespace Mindforge.API.Models.Flashcards
public double AttentionPercentage { get; set; } public double AttentionPercentage { get; set; }
} }
public class FlashcardRagCard public class FlashcardRagLibrary
{ {
public long CardId { get; set; }
public long LibraryId { get; set; } public long LibraryId { get; set; }
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty; public string FileName { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty; public string Subject { get; set; } = string.Empty;
public string SubSubject { 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 CorrectCount { get; set; }
public int IncorrectCount { get; set; } public int IncorrectCount { get; set; }
public int CardCount { get; set; }
public int TotalAnswers { get; set; } public int TotalAnswers { get; set; }
public double PerformanceRate { get; set; } public double PerformanceRate { get; set; }
public DateTime? LastReviewedAt { get; set; } public DateTime? LastReviewedAt { get; set; }

View File

@@ -103,26 +103,31 @@ namespace Mindforge.API.Services
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var cards = await _flashcardRepository.GetAllCardsWithLibraryAsync(); var cards = await _flashcardRepository.GetAllCardsWithLibraryAsync();
var ragCards = cards var ragLibraries = cards
.Select(card => BuildRagCard(card, now)) .GroupBy(card => card.LibraryId)
.Select(group => BuildRagLibrary(group.ToList(), now))
.ToList(); .ToList();
var subjectGroups = ragCards var subjectGroups = ragLibraries
.GroupBy(card => card.Subject, StringComparer.OrdinalIgnoreCase) .GroupBy(library => library.Subject, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(subjectGroup => .Select(subjectGroup =>
{ {
var subSubjectGroups = subjectGroup var subSubjectGroups = subjectGroup
.GroupBy(card => card.SubSubject, StringComparer.OrdinalIgnoreCase) .GroupBy(library => library.SubSubject, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(subSubjectGroup => .Select(subSubjectGroup =>
{ {
var subSubjectCards = subSubjectGroup.ToList(); var subSubjectLibraries = subSubjectGroup
.OrderBy(library => StatusSortOrder(library.RagStatus))
.ThenBy(library => library.FileName, StringComparer.OrdinalIgnoreCase)
.ToList();
return new FlashcardRagSubSubjectGroup return new FlashcardRagSubSubjectGroup
{ {
SubSubject = subSubjectGroup.Key, SubSubject = subSubjectGroup.Key,
Summary = BuildSummary(subSubjectCards), Summary = BuildSummary(subSubjectLibraries),
Cards = subSubjectCards Libraries = subSubjectLibraries
}; };
}) })
.ToList(); .ToList();
@@ -312,32 +317,37 @@ namespace Mindforge.API.Services
return string.Join(" / ", segments[subSubjectStart..subSubjectEnd]); return string.Join(" / ", segments[subSubjectStart..subSubjectEnd]);
} }
private static FlashcardRagCard BuildRagCard(FlashcardCardWithLibrary card, DateTime referenceTime) private static FlashcardRagLibrary BuildRagLibrary(
IReadOnlyList<FlashcardCardWithLibrary> cards,
DateTime referenceTime)
{ {
var subject = string.IsNullOrWhiteSpace(card.Subject) var firstCard = cards[0];
? ExtractSubject(card.FilePath) var subject = string.IsNullOrWhiteSpace(firstCard.Subject)
: card.Subject; ? ExtractSubject(firstCard.FilePath)
var subSubject = ExtractSubSubject(card.FilePath); : firstCard.Subject;
var totalAnswers = card.CorrectCount + card.IncorrectCount; var subSubject = ExtractSubSubject(firstCard.FilePath);
var correctCount = cards.Sum(card => card.CorrectCount);
var incorrectCount = cards.Sum(card => card.IncorrectCount);
var totalAnswers = correctCount + incorrectCount;
var lastReviewedAt = cards.Max(card => card.LastReviewedAt);
var performanceRate = totalAnswers == 0 var performanceRate = totalAnswers == 0
? 0 ? 0
: (double)card.CorrectCount / totalAnswers; : (double)correctCount / totalAnswers;
return new FlashcardRagCard return new FlashcardRagLibrary
{ {
CardId = card.Id, LibraryId = firstCard.LibraryId,
LibraryId = card.LibraryId, FilePath = firstCard.FilePath,
FileName = card.FileName, FileName = firstCard.FileName,
Subject = subject, Subject = subject,
SubSubject = subSubject, SubSubject = subSubject,
Front = card.Front, CorrectCount = correctCount,
Back = card.Back, IncorrectCount = incorrectCount,
CorrectCount = card.CorrectCount, CardCount = cards.Count,
IncorrectCount = card.IncorrectCount,
TotalAnswers = totalAnswers, TotalAnswers = totalAnswers,
PerformanceRate = performanceRate, PerformanceRate = performanceRate,
LastReviewedAt = card.LastReviewedAt, LastReviewedAt = lastReviewedAt,
RagStatus = DetermineRagStatus(card.LastReviewedAt, performanceRate, referenceTime) RagStatus = DetermineRagStatus(lastReviewedAt, performanceRate, referenceTime)
}; };
} }
@@ -369,13 +379,13 @@ namespace Mindforge.API.Services
return "Amber"; return "Amber";
} }
private static FlashcardRagSummary BuildSummary(IEnumerable<FlashcardRagCard> cards) private static FlashcardRagSummary BuildSummary(IEnumerable<FlashcardRagLibrary> libraries)
{ {
var cardList = cards.ToList(); var libraryList = libraries.ToList();
var greenCount = cardList.Count(card => card.RagStatus == "Green"); var greenCount = libraryList.Count(library => library.RagStatus == "Green");
var amberCount = cardList.Count(card => card.RagStatus == "Amber"); var amberCount = libraryList.Count(library => library.RagStatus == "Amber");
var redCount = cardList.Count(card => card.RagStatus == "Red"); var redCount = libraryList.Count(library => library.RagStatus == "Red");
var greyCount = cardList.Count(card => card.RagStatus == "Grey"); var greyCount = libraryList.Count(library => library.RagStatus == "Grey");
var activeCount = greenCount + amberCount + redCount; var activeCount = greenCount + amberCount + redCount;
var greenPercentage = activeCount == 0 var greenPercentage = activeCount == 0
@@ -397,6 +407,17 @@ namespace Mindforge.API.Services
}; };
} }
private static int StatusSortOrder(string status)
{
return status switch
{
"Red" => 0,
"Amber" => 1,
"Green" => 2,
_ => 3
};
}
private class FlashcardJsonPayload private class FlashcardJsonPayload
{ {
public List<FlashcardJsonCard> Flashcards { get; set; } = []; public List<FlashcardJsonCard> Flashcards { get; set; } = [];

View File

@@ -1,6 +1,6 @@
.spaced-review-container { .spaced-review-container {
width: 100%; width: 100%;
max-width: 980px; max-width: 1020px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -48,16 +48,16 @@
.spaced-review-filter { .spaced-review-filter {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.45rem;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px; border-radius: 999px;
padding: 0.35rem 0.7rem; padding: 0.35rem 0.8rem;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
font-size: 0.88rem; font-size: 0.88rem;
} }
.spaced-review-filter input[type="checkbox"], .spaced-review-filter input[type="checkbox"],
.spaced-review-subsubject-item input[type="checkbox"] { .spaced-review-library-item input[type="checkbox"] {
width: 15px; width: 15px;
height: 15px; height: 15px;
accent-color: var(--color-accent); accent-color: var(--color-accent);
@@ -77,7 +77,7 @@
.spaced-review-subject-header h3 { .spaced-review-subject-header h3 {
margin: 0; margin: 0;
font-size: 1.03rem; font-size: 1.05rem;
} }
.spaced-review-subject-header p { .spaced-review-subject-header p {
@@ -95,39 +95,171 @@
.spaced-review-subsubject-list { .spaced-review-subsubject-list {
display: grid; display: grid;
gap: 0.6rem; gap: 0.8rem;
margin-top: 0.85rem; margin-top: 0.95rem;
} }
.spaced-review-subsubject-item { .spaced-review-subsubject-block {
display: flex;
align-items: flex-start;
gap: 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px; border-radius: 10px;
padding: 0.7rem;
background: rgba(255, 255, 255, 0.03);
}
.spaced-review-subsubject-header {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-bottom: 0.6rem;
}
.spaced-review-subsubject-header strong {
font-size: 0.93rem;
}
.spaced-review-subsubject-header span {
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.78);
}
.spaced-review-subsubject-header small {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.6);
}
.spaced-review-library-list {
display: grid;
gap: 0.55rem;
}
.spaced-review-library-item {
display: flex;
align-items: center;
gap: 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-left-width: 4px;
border-radius: 10px;
padding: 0.6rem 0.7rem; padding: 0.6rem 0.7rem;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
cursor: pointer; cursor: pointer;
} }
.spaced-review-subsubject-texts { .spaced-review-library-item.selected {
box-shadow: 0 0 0 1px rgba(var(--color-accent-rgb), 0.45);
}
.spaced-review-library-texts {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.12rem;
min-width: 0;
} }
.spaced-review-subsubject-texts strong { .spaced-review-library-texts strong {
font-size: 0.93rem; font-size: 0.91rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.spaced-review-subsubject-texts span { .spaced-review-library-texts span {
font-size: 0.82rem; font-size: 0.8rem;
color: rgba(255, 255, 255, 0.78); color: rgba(255, 255, 255, 0.78);
} }
.spaced-review-subsubject-texts small { .spaced-review-library-texts small {
font-size: 0.78rem; font-size: 0.77rem;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.62);
}
.rag-badge,
.rag-badge-inline {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
padding: 0.22rem 0.55rem;
font-size: 0.76rem;
font-weight: 700;
line-height: 1;
}
.rag-icon {
width: 16px;
height: 16px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.74rem;
font-weight: 700;
background: rgba(255, 255, 255, 0.18);
}
.rag-red {
border-left-color: #ff5d5d;
}
.rag-red .rag-badge,
.rag-badge.rag-red,
.spaced-review-filter.rag-red {
background: rgba(255, 93, 93, 0.15);
}
.rag-red .rag-icon,
.rag-badge.rag-red .rag-icon,
.rag-badge-inline.rag-red .rag-icon {
background: rgba(255, 93, 93, 0.35);
}
.rag-amber {
border-left-color: #ffbe55;
}
.rag-amber .rag-badge,
.rag-badge.rag-amber,
.spaced-review-filter.rag-amber {
background: rgba(255, 190, 85, 0.16);
}
.rag-amber .rag-icon,
.rag-badge.rag-amber .rag-icon,
.rag-badge-inline.rag-amber .rag-icon {
background: rgba(255, 190, 85, 0.36);
}
.rag-green {
border-left-color: #46d18a;
}
.rag-green .rag-badge,
.rag-badge.rag-green,
.spaced-review-filter.rag-green {
background: rgba(70, 209, 138, 0.16);
}
.rag-green .rag-icon,
.rag-badge.rag-green .rag-icon,
.rag-badge-inline.rag-green .rag-icon {
background: rgba(70, 209, 138, 0.35);
}
.rag-grey {
border-left-color: #9aa6b5;
}
.rag-grey .rag-badge,
.rag-badge.rag-grey,
.spaced-review-filter.rag-grey {
background: rgba(154, 166, 181, 0.16);
}
.rag-grey .rag-icon,
.rag-badge.rag-grey .rag-icon,
.rag-badge-inline.rag-grey .rag-icon {
background: rgba(154, 166, 181, 0.35);
} }
.spaced-review-footer { .spaced-review-footer {
@@ -182,6 +314,11 @@
.spaced-review-card header { .spaced-review-card header {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
flex-wrap: wrap;
} }
.spaced-review-card small { .spaced-review-card small {
@@ -216,6 +353,10 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.spaced-review-library-item {
flex-wrap: wrap;
}
.spaced-review-session-actions { .spaced-review-session-actions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { import {
MindforgeApiService, MindforgeApiService,
type FlashcardCard, type FlashcardCard,
type FlashcardRagCard,
type FlashcardRagDashboardResponse, type FlashcardRagDashboardResponse,
type FlashcardRagLibrary,
type FlashcardRagStatus, type FlashcardRagStatus,
} from '../services/MindforgeApiService'; } from '../services/MindforgeApiService';
import { Button } from './Button'; import { Button } from './Button';
@@ -12,15 +12,29 @@ import './SpacedReviewComponent.css';
interface RagStatusOption { interface RagStatusOption {
status: FlashcardRagStatus; status: FlashcardRagStatus;
label: string; label: string;
icon: string;
}
interface RagStatusMeta {
label: string;
icon: string;
className: string;
} }
const STATUS_OPTIONS: RagStatusOption[] = [ const STATUS_OPTIONS: RagStatusOption[] = [
{ status: 'Red', label: 'Vermelho' }, { status: 'Red', label: 'Vermelho', icon: '!' },
{ status: 'Amber', label: 'Amarelo' }, { status: 'Amber', label: 'Amarelo', icon: '*' },
{ status: 'Green', label: 'Verde' }, { status: 'Green', label: 'Verde', icon: 'v' },
{ status: 'Grey', label: 'Cinza' }, { status: 'Grey', label: 'Cinza', icon: '-' },
]; ];
const STATUS_META_BY_STATUS: Record<FlashcardRagStatus, RagStatusMeta> = {
Red: { label: 'Vermelho', icon: '!', className: 'rag-red' },
Amber: { label: 'Amarelo', icon: '*', className: 'rag-amber' },
Green: { label: 'Verde', icon: 'v', className: 'rag-green' },
Grey: { label: 'Cinza', icon: '-', className: 'rag-grey' },
};
const STATUS_PRIORITY: Record<FlashcardRagStatus, number> = { const STATUS_PRIORITY: Record<FlashcardRagStatus, number> = {
Red: 0, Red: 0,
Amber: 1, Amber: 1,
@@ -28,16 +42,12 @@ const STATUS_PRIORITY: Record<FlashcardRagStatus, number> = {
Grey: 3, Grey: 3,
}; };
function buildGroupKey(subject: string, subSubject: string) {
return `${subject}::${subSubject}`;
}
function shuffleCards(cards: FlashcardCard[]) { function shuffleCards(cards: FlashcardCard[]) {
const shuffled = [...cards]; const shuffled = [...cards];
for (let i = shuffled.length - 1; i > 0; i--) { for (let index = shuffled.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (i + 1)); const randomIndex = Math.floor(Math.random() * (index + 1));
[shuffled[i], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[i]]; [shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]];
} }
return shuffled; return shuffled;
@@ -59,12 +69,25 @@ function formatPerformance(rate: number) {
return `${Math.round(rate * 100)}%`; return `${Math.round(rate * 100)}%`;
} }
function orderCardsForSession(cards: FlashcardCard[], ragByCardId: Map<number, FlashcardRagCard>) { function formatLastReviewed(value?: string | null) {
if (!value) {
return 'Nunca revisado';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Data invalida';
}
return date.toLocaleDateString('pt-BR');
}
function orderCardsForSession(cards: FlashcardCard[], ragByLibraryId: Map<number, FlashcardRagLibrary>) {
const buckets: FlashcardCard[][] = [[], [], [], []]; const buckets: FlashcardCard[][] = [[], [], [], []];
cards.forEach((card) => { cards.forEach((card) => {
const ragCard = ragByCardId.get(card.id); const ragLibrary = ragByLibraryId.get(card.libraryId);
const status = ragCard?.ragStatus || 'Grey'; const status = ragLibrary?.ragStatus || 'Grey';
buckets[STATUS_PRIORITY[status]].push(card); buckets[STATUS_PRIORITY[status]].push(card);
}); });
@@ -76,10 +99,10 @@ export function SpacedReviewComponent() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedStatuses, setSelectedStatuses] = useState<FlashcardRagStatus[]>(['Red', 'Amber']); const [selectedStatuses, setSelectedStatuses] = useState<FlashcardRagStatus[]>(['Red', 'Amber']);
const [selectedGroupKeys, setSelectedGroupKeys] = useState<string[]>([]); const [selectedLibraryIds, setSelectedLibraryIds] = useState<number[]>([]);
const [startingSession, setStartingSession] = useState(false); const [startingSession, setStartingSession] = useState(false);
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]); const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
const [sessionSourceCards, setSessionSourceCards] = useState<FlashcardRagCard[]>([]); const [sessionLibraries, setSessionLibraries] = useState<FlashcardRagLibrary[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false); const [showAnswer, setShowAnswer] = useState(false);
const [submittingAnswer, setSubmittingAnswer] = useState(false); const [submittingAnswer, setSubmittingAnswer] = useState(false);
@@ -92,19 +115,19 @@ export function SpacedReviewComponent() {
const response = await MindforgeApiService.getFlashcardRagStatus(); const response = await MindforgeApiService.getFlashcardRagStatus();
setDashboard(response); setDashboard(response);
const allGroupKeys = response.subjects.flatMap((subjectGroup) => const allLibraryIds = response.subjects.flatMap((subjectGroup) =>
subjectGroup.subSubjects.map((subSubjectGroup) => subjectGroup.subSubjects.flatMap((subSubjectGroup) =>
buildGroupKey(subjectGroup.subject, subSubjectGroup.subSubject)), subSubjectGroup.libraries.map((library) => library.libraryId)),
); );
setSelectedGroupKeys((current) => { setSelectedLibraryIds((current) => {
if (!preserveSelection || current.length === 0) { if (!preserveSelection || current.length === 0) {
return allGroupKeys; return allLibraryIds;
} }
const available = new Set(allGroupKeys); const available = new Set(allLibraryIds);
const kept = current.filter((key) => available.has(key)); const kept = current.filter((libraryId) => available.has(libraryId));
return kept.length > 0 ? kept : allGroupKeys; return kept.length > 0 ? kept : allLibraryIds;
}); });
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Falha ao carregar status de revisao espacada.'); setError(err?.message || 'Falha ao carregar status de revisao espacada.');
@@ -120,6 +143,7 @@ export function SpacedReviewComponent() {
if (cancelled) { if (cancelled) {
return; return;
} }
await loadDashboard(false); await loadDashboard(false);
} }
@@ -129,30 +153,32 @@ export function SpacedReviewComponent() {
}; };
}, []); }, []);
const ragCards = useMemo(() => { const allRagLibraries = useMemo(() => {
if (!dashboard) { if (!dashboard) {
return []; return [];
} }
return dashboard.subjects.flatMap((subjectGroup) => return dashboard.subjects.flatMap((subjectGroup) =>
subjectGroup.subSubjects.flatMap((subSubjectGroup) => subSubjectGroup.cards)); subjectGroup.subSubjects.flatMap((subSubjectGroup) => subSubjectGroup.libraries));
}, [dashboard]); }, [dashboard]);
const selectedRagCards = useMemo(() => { const selectedRagLibraries = useMemo(() => {
const selectedGroups = new Set(selectedGroupKeys); const statusSet = new Set(selectedStatuses);
const selectedStatusSet = new Set(selectedStatuses); const libraryIdSet = new Set(selectedLibraryIds);
return ragCards.filter((card) => return allRagLibraries.filter((library) =>
selectedGroups.has(buildGroupKey(card.subject, card.subSubject)) libraryIdSet.has(library.libraryId) && statusSet.has(library.ragStatus));
&& selectedStatusSet.has(card.ragStatus)); }, [allRagLibraries, selectedLibraryIds, selectedStatuses]);
}, [ragCards, selectedGroupKeys, selectedStatuses]);
const sessionSourceById = useMemo(() => { const sessionLibraryById = useMemo(() => {
return new Map(sessionSourceCards.map((card) => [card.cardId, card])); return new Map(sessionLibraries.map((library) => [library.libraryId, library]));
}, [sessionSourceCards]); }, [sessionLibraries]);
const currentCard = sessionCards[currentIndex]; const currentCard = sessionCards[currentIndex];
const currentCardMetadata = currentCard ? sessionSourceById.get(currentCard.id) : undefined; const currentLibrary = currentCard ? sessionLibraryById.get(currentCard.libraryId) : undefined;
const currentStatusMeta = currentLibrary
? STATUS_META_BY_STATUS[currentLibrary.ragStatus]
: STATUS_META_BY_STATUS.Grey;
const progressPercent = sessionCards.length > 0 const progressPercent = sessionCards.length > 0
? ((currentIndex + 1) / sessionCards.length) * 100 ? ((currentIndex + 1) / sessionCards.length) * 100
@@ -167,13 +193,13 @@ export function SpacedReviewComponent() {
setSelectedStatuses([...selectedStatuses, status]); setSelectedStatuses([...selectedStatuses, status]);
}; };
const toggleGroup = (groupKey: string) => { const toggleLibrary = (libraryId: number) => {
if (selectedGroupKeys.includes(groupKey)) { if (selectedLibraryIds.includes(libraryId)) {
setSelectedGroupKeys(selectedGroupKeys.filter((key) => key !== groupKey)); setSelectedLibraryIds(selectedLibraryIds.filter((id) => id !== libraryId));
return; return;
} }
setSelectedGroupKeys([...selectedGroupKeys, groupKey]); setSelectedLibraryIds([...selectedLibraryIds, libraryId]);
}; };
const startSession = async () => { const startSession = async () => {
@@ -182,13 +208,13 @@ export function SpacedReviewComponent() {
return; return;
} }
if (selectedGroupKeys.length === 0) { if (selectedLibraryIds.length === 0) {
setError('Selecione ao menos uma submateria para iniciar a revisao.'); setError('Selecione ao menos um arquivo para iniciar a revisao.');
return; return;
} }
if (selectedRagCards.length === 0) { if (selectedRagLibraries.length === 0) {
setError('Nenhum card encontrado com os filtros selecionados.'); setError('Nenhum arquivo encontrado com os filtros selecionados.');
return; return;
} }
@@ -196,19 +222,19 @@ export function SpacedReviewComponent() {
setError(null); setError(null);
try { try {
const ragByCardId = new Map(selectedRagCards.map((card) => [card.cardId, card])); const ragByLibraryId = new Map(selectedRagLibraries.map((library) => [library.libraryId, library]));
const libraryIds = Array.from(new Set(selectedRagCards.map((card) => card.libraryId))); const libraryIds = Array.from(new Set(selectedRagLibraries.map((library) => library.libraryId)));
const response = await MindforgeApiService.createFlashcardReviewSession({ libraryIds }); const response = await MindforgeApiService.createFlashcardReviewSession({ libraryIds });
const selectedCardIds = new Set(selectedRagCards.map((card) => card.cardId)); const allowedLibraryIds = new Set(libraryIds);
const filteredCards = response.cards.filter((card) => selectedCardIds.has(card.id)); const filteredCards = response.cards.filter((card) => allowedLibraryIds.has(card.libraryId));
const orderedCards = orderCardsForSession(filteredCards, ragByCardId); const orderedCards = orderCardsForSession(filteredCards, ragByLibraryId);
if (orderedCards.length === 0) { if (orderedCards.length === 0) {
setError('Os filtros selecionados nao retornaram cards para revisar.'); setError('Os filtros selecionados nao retornaram cards para revisar.');
return; return;
} }
setSessionSourceCards(selectedRagCards); setSessionLibraries(selectedRagLibraries);
setSessionCards(orderedCards); setSessionCards(orderedCards);
setCurrentIndex(0); setCurrentIndex(0);
setShowAnswer(false); setShowAnswer(false);
@@ -221,7 +247,7 @@ export function SpacedReviewComponent() {
const endSession = () => { const endSession = () => {
setSessionCards([]); setSessionCards([]);
setSessionSourceCards([]); setSessionLibraries([]);
setCurrentIndex(0); setCurrentIndex(0);
setShowAnswer(false); setShowAnswer(false);
setSubmittingAnswer(false); setSubmittingAnswer(false);
@@ -232,6 +258,7 @@ export function SpacedReviewComponent() {
if (currentIndex === 0) { if (currentIndex === 0) {
return; return;
} }
setCurrentIndex(currentIndex - 1); setCurrentIndex(currentIndex - 1);
setShowAnswer(false); setShowAnswer(false);
}; };
@@ -267,7 +294,7 @@ export function SpacedReviewComponent() {
return ( return (
<div className="spaced-review-container"> <div className="spaced-review-container">
<h2 className="title spaced-review-title">Revisao espacada</h2> <h2 className="title spaced-review-title">Revisao espacada</h2>
<p className="subtitle">Acompanhe o status RAG dos cards por materia e submateria.</p> <p className="subtitle">Acompanhe o status RAG por arquivo de flashcards.</p>
{error && <div className="spaced-review-error">{error}</div>} {error && <div className="spaced-review-error">{error}</div>}
@@ -282,13 +309,16 @@ export function SpacedReviewComponent() {
<> <>
<div className="spaced-review-filters"> <div className="spaced-review-filters">
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
<label key={option.status} className="spaced-review-filter"> <label key={option.status} className={`spaced-review-filter ${STATUS_META_BY_STATUS[option.status].className}`}>
<input <input
type="checkbox" type="checkbox"
checked={selectedStatuses.includes(option.status)} checked={selectedStatuses.includes(option.status)}
onChange={() => toggleStatus(option.status)} onChange={() => toggleStatus(option.status)}
/> />
<span>{option.label}</span> <span className="rag-badge-inline">
<span className="rag-icon">{option.icon}</span>
{option.label}
</span>
</label> </label>
))} ))}
</div> </div>
@@ -311,31 +341,52 @@ export function SpacedReviewComponent() {
</header> </header>
<div className="spaced-review-subsubject-list"> <div className="spaced-review-subsubject-list">
{subjectGroup.subSubjects.map((subSubjectGroup) => { {subjectGroup.subSubjects.map((subSubjectGroup) => (
const groupKey = buildGroupKey(subjectGroup.subject, subSubjectGroup.subSubject); <div key={`${subjectGroup.subject}::${subSubjectGroup.subSubject}`} className="spaced-review-subsubject-block">
const checked = selectedGroupKeys.includes(groupKey); <div className="spaced-review-subsubject-header">
<strong>{subSubjectGroup.subSubject}</strong>
<span>{summaryText(
subSubjectGroup.summary.activeCount,
subSubjectGroup.summary.greenPercentage,
subSubjectGroup.summary.attentionPercentage,
)}</span>
<small>
Arquivos: {subSubjectGroup.libraries.length} | Cinza: {subSubjectGroup.summary.greyCount}
</small>
</div>
return ( <div className="spaced-review-library-list">
<label key={groupKey} className="spaced-review-subsubject-item"> {subSubjectGroup.libraries.map((library) => {
<input const statusMeta = STATUS_META_BY_STATUS[library.ragStatus];
type="checkbox" const selected = selectedLibraryIds.includes(library.libraryId);
checked={checked}
onChange={() => toggleGroup(groupKey)} return (
/> <label
<div className="spaced-review-subsubject-texts"> key={library.libraryId}
<strong>{subSubjectGroup.subSubject}</strong> className={`spaced-review-library-item ${statusMeta.className} ${selected ? 'selected' : ''}`}
<span>{summaryText( >
subSubjectGroup.summary.activeCount, <input
subSubjectGroup.summary.greenPercentage, type="checkbox"
subSubjectGroup.summary.attentionPercentage, checked={selected}
)}</span> onChange={() => toggleLibrary(library.libraryId)}
<small> />
Cards: {subSubjectGroup.cards.length} | Cinza: {subSubjectGroup.summary.greyCount} <div className="spaced-review-library-texts">
</small> <strong>{library.fileName}</strong>
</div> <span>
</label> Cards: {library.cardCount} | Desempenho: {formatPerformance(library.performanceRate)}
); </span>
})} <small>Ultima revisao: {formatLastReviewed(library.lastReviewedAt)}</small>
</div>
<span className={`rag-badge ${statusMeta.className}`}>
<span className="rag-icon">{statusMeta.icon}</span>
{statusMeta.label}
</span>
</label>
);
})}
</div>
</div>
))}
</div> </div>
</section> </section>
))} ))}
@@ -345,11 +396,11 @@ export function SpacedReviewComponent() {
<div className="spaced-review-footer"> <div className="spaced-review-footer">
<p> <p>
Cards selecionados: <strong>{selectedRagCards.length}</strong> Arquivos selecionados: <strong>{selectedRagLibraries.length}</strong>
</p> </p>
<Button <Button
variant="primary" variant="primary"
disabled={startingSession || selectedRagCards.length === 0} disabled={startingSession || selectedRagLibraries.length === 0}
onClick={startSession} onClick={startSession}
> >
{startingSession ? 'Iniciando...' : 'Iniciar Revisao Espacada'} {startingSession ? 'Iniciando...' : 'Iniciar Revisao Espacada'}
@@ -370,8 +421,12 @@ export function SpacedReviewComponent() {
<article className="spaced-review-card"> <article className="spaced-review-card">
<header> <header>
<small> <small>
{currentCardMetadata?.fileName || 'Arquivo'} - {currentCardMetadata?.subject || 'Geral'} - {currentCardMetadata?.subSubject || 'Geral'} {currentLibrary?.fileName || 'Arquivo'} - {currentLibrary?.subject || 'Geral'} - {currentLibrary?.subSubject || 'Geral'}
</small> </small>
<span className={`rag-badge ${currentStatusMeta.className}`}>
<span className="rag-icon">{currentStatusMeta.icon}</span>
{currentStatusMeta.label}
</span>
</header> </header>
<h3>Frente</h3> <h3>Frente</h3>
@@ -382,8 +437,8 @@ export function SpacedReviewComponent() {
<h3>Verso</h3> <h3>Verso</h3>
<p>{currentCard.back}</p> <p>{currentCard.back}</p>
<div className="spaced-review-card-meta"> <div className="spaced-review-card-meta">
<span>Desempenho: {currentCardMetadata ? formatPerformance(currentCardMetadata.performanceRate) : '-'}</span> <span>Desempenho do arquivo: {currentLibrary ? formatPerformance(currentLibrary.performanceRate) : '-'}</span>
<span>Status: {currentCardMetadata?.ragStatus || 'Grey'}</span> <span>Ultima revisao: {formatLastReviewed(currentLibrary?.lastReviewedAt)}</span>
</div> </div>
</> </>
)} )}

View File

@@ -80,14 +80,13 @@ export interface FlashcardReviewAnswerRequest {
export type FlashcardRagStatus = 'Grey' | 'Red' | 'Amber' | 'Green'; export type FlashcardRagStatus = 'Grey' | 'Red' | 'Amber' | 'Green';
export interface FlashcardRagCard { export interface FlashcardRagLibrary {
cardId: number;
libraryId: number; libraryId: number;
filePath: string;
fileName: string; fileName: string;
subject: string; subject: string;
subSubject: string; subSubject: string;
front: string; cardCount: number;
back: string;
correctCount: number; correctCount: number;
incorrectCount: number; incorrectCount: number;
totalAnswers: number; totalAnswers: number;
@@ -109,7 +108,7 @@ export interface FlashcardRagSummary {
export interface FlashcardRagSubSubjectGroup { export interface FlashcardRagSubSubjectGroup {
subSubject: string; subSubject: string;
summary: FlashcardRagSummary; summary: FlashcardRagSummary;
cards: FlashcardRagCard[]; libraries: FlashcardRagLibrary[];
} }
export interface FlashcardRagSubjectGroup { export interface FlashcardRagSubjectGroup {

View File

@@ -337,7 +337,7 @@ Exemplos:
### Tabelas de Flashcard ### Tabelas de Flashcard
- `flashcard_libraries`: `id`, `file_path` (unico), `file_name`, `subject`, `difficulty`, `card_count`, `created_at`, `updated_at`. - `flashcard_libraries`: `id`, `file_path` (unico), `file_name`, `subject`, `difficulty`, `card_count`, `created_at`, `updated_at`.
- `flashcards`: `id`, `library_id`, `front`, `back`, `position`, `correct_count`, `incorrect_count`, `created_at`. - `flashcards`: `id`, `library_id`, `front`, `back`, `position`, `correct_count`, `incorrect_count`, `last_reviewed_at`, `created_at`.
### API de Flashcards (v1) ### API de Flashcards (v1)
- `POST /api/v1/flashcard/generate` - `POST /api/v1/flashcard/generate`
@@ -377,16 +377,16 @@ Exemplos:
## Atualizacao - Revisao Espacada RAG (2026-06-01) ## Atualizacao - Revisao Espacada RAG (2026-06-01)
### Mudancas de Arquitetura ### Mudancas de Arquitetura
- O dominio de flashcards agora guarda `last_reviewed_at` por card para permitir classificacao temporal. - O dominio de flashcards guarda `last_reviewed_at` por card.
- O status RAG e calculado em tempo de leitura (nao e persistido), para evitar status defasado. - O status RAG da revisao espacada e calculado em tempo de leitura **por biblioteca/arquivo** (grupo de cards), nao por card.
- O agrupamento de dashboard usa `subject` + `subSubject`, onde `subSubject` vem dos segmentos do caminho apos a materia e antes do arquivo. - 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 ### Regras RAG por Arquivo (biblioteca)
- `Grey`: card nunca revisado (`last_reviewed_at` nulo). - `Grey`: nenhum card da biblioteca possui `last_reviewed_at`.
- `Red`: revisado ha 40 dias ou mais **ou** desempenho `< 40%`. - `Red`: ultima revisao da biblioteca ha 40 dias ou mais **ou** desempenho agregado `< 40%`.
- `Amber`: revisado ha 30 dias ou mais **ou** desempenho `<= 60%`. - `Amber`: ultima revisao da biblioteca ha 30 dias ou mais **ou** desempenho agregado `<= 60%`.
- `Green`: revisado ha menos de 30 dias **e** desempenho `> 60%`. - `Green`: ultima revisao da biblioteca abaixo de 30 dias **e** desempenho agregado `> 60%`.
- Desempenho: `correct_count / (correct_count + incorrect_count)`. - Desempenho agregado: `sum(correct_count) / (sum(correct_count) + sum(incorrect_count))`.
### Banco e Repositorio ### Banco e Repositorio
- Tabela `flashcards` recebeu coluna `last_reviewed_at TIMESTAMPTZ NULL` (migracao idempotente no startup). - Tabela `flashcards` recebeu coluna `last_reviewed_at TIMESTAMPTZ NULL` (migracao idempotente no startup).
@@ -395,16 +395,17 @@ Exemplos:
### API de Flashcards (v1) ### API de Flashcards (v1)
- Novo endpoint `GET /api/v1/flashcard/rag-status`: - Novo endpoint `GET /api/v1/flashcard/rag-status`:
- Retorna dashboard de revisao espacada com grupos por materia/submateria. - Retorna dashboard de revisao espacada com grupos por materia/submateria.
- Inclui cards com `ragStatus`, `performanceRate`, `totalAnswers` e `lastReviewedAt`. - Inclui bibliotecas/arquivos com `ragStatus`, `performanceRate`, `totalAnswers` e `lastReviewedAt`.
- Inclui sumarios por materia/submateria com percentuais: - Inclui sumarios por materia/submateria com percentuais:
- Verde = `green / (green + amber + red)` - Verde = `green / (green + amber + red)`
- Atencao = `(amber + red) / (green + amber + red)` - Atencao = `(amber + red) / (green + amber + red)`
- Cards cinza ficam fora do denominador. - Arquivos cinza ficam fora do denominador.
### Frontend ### Frontend
- Novo modulo `Revisao Espacada` abaixo de `Revisao Flashcards` na sidebar. - Novo modulo `Revisao Espacada` abaixo de `Revisao Flashcards` na sidebar.
- O painel mostra: - O painel mostra:
- status agregados por materia e submateria; - status agregados por materia e submateria;
- status por arquivo com destaque visual (cores e icones);
- filtros por status RAG (Vermelho, Amarelo, Verde, Cinza); - filtros por status RAG (Vermelho, Amarelo, Verde, Cinza);
- total de cards selecionados para revisao. - total de arquivos selecionados para revisao.
- A sessao de revisao espacada reutiliza o fluxo de resposta (`Acertei`/`Errei`) e prioriza cards por status (Red, Amber, Green, Grey). - A sessao de revisao espacada reutiliza o fluxo de resposta (`Acertei`/`Errei`) e prioriza cards de arquivos por status (Red, Amber, Green, Grey).