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