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:
@@ -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; }
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,18 +341,9 @@ 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">
|
||||||
|
|
||||||
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>
|
<strong>{subSubjectGroup.subSubject}</strong>
|
||||||
<span>{summaryText(
|
<span>{summaryText(
|
||||||
subSubjectGroup.summary.activeCount,
|
subSubjectGroup.summary.activeCount,
|
||||||
@@ -330,13 +351,43 @@ export function SpacedReviewComponent() {
|
|||||||
subSubjectGroup.summary.attentionPercentage,
|
subSubjectGroup.summary.attentionPercentage,
|
||||||
)}</span>
|
)}</span>
|
||||||
<small>
|
<small>
|
||||||
Cards: {subSubjectGroup.cards.length} | Cinza: {subSubjectGroup.summary.greyCount}
|
Arquivos: {subSubjectGroup.libraries.length} | Cinza: {subSubjectGroup.summary.greyCount}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user