aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-05 23:54:46 +0900
committernsfisis <nsfisis@gmail.com>2026-02-05 23:54:46 +0900
commit24da9e91b9d5284a104cc207e59a619f3c48bb7f (patch)
tree9970fd96d447d25361329b11a32981ccdc1c1499 /src/client/pages
parent19bf3a9b2cf91e49af8c70f974a5b3fcf2bcd869 (diff)
downloadkioku-24da9e91b9d5284a104cc207e59a619f3c48bb7f.tar.gz
kioku-24da9e91b9d5284a104cc207e59a619f3c48bb7f.tar.zst
kioku-24da9e91b9d5284a104cc207e59a619f3c48bb7f.zip
feat(deck): display card counts by state on deck detail page
Show separate counts for New, Learning, and Review cards instead of a single "Due Today" count. Uses FSRS CardState to categorize cards with color-coded display (blue/orange/green). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx30
-rw-r--r--src/client/pages/DeckDetailPage.tsx56
2 files changed, 69 insertions, 17 deletions
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index 41f42fd..815dff1 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -249,7 +249,7 @@ describe("DeckDetailPage", () => {
initialCards: mockCards,
});
- const totalCardsLabel = screen.getByText("Total Cards");
+ const totalCardsLabel = screen.getByText("Total");
expect(totalCardsLabel).toBeDefined();
// Find the count within the same container
const totalCardsContainer = totalCardsLabel.parentElement;
@@ -258,17 +258,33 @@ describe("DeckDetailPage", () => {
);
});
- it("displays due card count", () => {
+ it("displays card counts by state", () => {
renderWithProviders({
initialDeck: mockDeck,
initialCards: mockCards,
});
- const dueLabel = screen.getByText("Due Today");
- expect(dueLabel).toBeDefined();
- // Find the count within the same container (one card is due)
- const dueContainer = dueLabel.parentElement;
- expect(dueContainer?.querySelector(".text-primary")?.textContent).toBe("1");
+ // New cards (state=0, but card-1 is not due yet, so 0)
+ const newLabel = screen.getByText("New");
+ expect(newLabel).toBeDefined();
+ const newContainer = newLabel.parentElement;
+ expect(newContainer?.querySelector(".text-info")?.textContent).toBe("0");
+
+ // Learning cards (state=1 or 3, none in mockCards)
+ const learningLabel = screen.getByText("Learning");
+ expect(learningLabel).toBeDefined();
+ const learningContainer = learningLabel.parentElement;
+ expect(learningContainer?.querySelector(".text-warning")?.textContent).toBe(
+ "0",
+ );
+
+ // Review cards (state=2, card-2 is due now)
+ const reviewLabel = screen.getByText("Review");
+ expect(reviewLabel).toBeDefined();
+ const reviewContainer = reviewLabel.parentElement;
+ expect(reviewContainer?.querySelector(".text-success")?.textContent).toBe(
+ "1",
+ );
});
it("does not display card list (cards are hidden)", () => {
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index 792bbe2..6bc89ba 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -25,6 +25,14 @@ function DeckHeader({ deckId }: { deckId: string }) {
);
}
+// CardState values from FSRS
+const CardState = {
+ New: 0,
+ Learning: 1,
+ Review: 2,
+ Relearning: 3,
+} as const;
+
function DeckStats({ deckId }: { deckId: string }) {
const { data: cards } = useAtomValue(cardsByDeckAtomFamily(deckId));
@@ -32,17 +40,37 @@ function DeckStats({ deckId }: { deckId: string }) {
const boundary = getEndOfStudyDayBoundary();
const dueCards = cards.filter((card) => new Date(card.due) < boundary);
+ // Count by card state
+ const newCards = dueCards.filter((card) => card.state === CardState.New);
+ const learningCards = dueCards.filter(
+ (card) =>
+ card.state === CardState.Learning || card.state === CardState.Relearning,
+ );
+ const reviewCards = dueCards.filter(
+ (card) => card.state === CardState.Review,
+ );
+
return (
<div className="bg-white rounded-xl border border-border/50 p-6 mb-6">
- <div className="grid grid-cols-2 gap-6">
+ <div className="grid grid-cols-4 gap-4">
<div>
- <p className="text-sm text-muted mb-1">Total Cards</p>
+ <p className="text-sm text-muted mb-1">Total</p>
<p className="text-2xl font-semibold text-ink">{cards.length}</p>
</div>
<div>
- <p className="text-sm text-muted mb-1">Due Today</p>
- <p className="text-2xl font-semibold text-primary">
- {dueCards.length}
+ <p className="text-sm text-muted mb-1">New</p>
+ <p className="text-2xl font-semibold text-info">{newCards.length}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted mb-1">Learning</p>
+ <p className="text-2xl font-semibold text-warning">
+ {learningCards.length}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm text-muted mb-1">Review</p>
+ <p className="text-2xl font-semibold text-success">
+ {reviewCards.length}
</p>
</div>
</div>
@@ -72,14 +100,22 @@ function DeckContent({ deckId }: { deckId: string }) {
<Suspense
fallback={
<div className="bg-white rounded-xl border border-border/50 p-6 mb-6">
- <div className="grid grid-cols-2 gap-6">
+ <div className="grid grid-cols-4 gap-4">
+ <div>
+ <div className="h-4 w-12 bg-muted/20 rounded animate-pulse mb-1" />
+ <div className="h-8 w-10 bg-muted/20 rounded animate-pulse" />
+ </div>
+ <div>
+ <div className="h-4 w-12 bg-muted/20 rounded animate-pulse mb-1" />
+ <div className="h-8 w-10 bg-muted/20 rounded animate-pulse" />
+ </div>
<div>
- <div className="h-4 w-20 bg-muted/20 rounded animate-pulse mb-1" />
- <div className="h-8 w-12 bg-muted/20 rounded animate-pulse" />
+ <div className="h-4 w-16 bg-muted/20 rounded animate-pulse mb-1" />
+ <div className="h-8 w-10 bg-muted/20 rounded animate-pulse" />
</div>
<div>
- <div className="h-4 w-20 bg-muted/20 rounded animate-pulse mb-1" />
- <div className="h-8 w-12 bg-muted/20 rounded animate-pulse" />
+ <div className="h-4 w-14 bg-muted/20 rounded animate-pulse mb-1" />
+ <div className="h-8 w-10 bg-muted/20 rounded animate-pulse" />
</div>
</div>
</div>