diff options
| author | Claude <noreply@anthropic.com> | 2026-01-12 08:57:51 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-01-12 08:57:51 +0000 |
| commit | 887630365ff0531a0556cb71a6b1be0956c41d06 (patch) | |
| tree | f0cebd927c2292a40f123a694c6aa561768ad7a3 /src | |
| parent | f8e4be9b36a16969ac53bd9ce12ce8064be10196 (diff) | |
| download | kioku-887630365ff0531a0556cb71a6b1be0956c41d06.tar.gz kioku-887630365ff0531a0556cb71a6b1be0956c41d06.tar.zst kioku-887630365ff0531a0556cb71a6b1be0956c41d06.zip | |
feat(deck): show due card count on deck list page
Display a badge with the number of cards due for study today next to
each deck name on the home page. The count is fetched along with deck
data from the API to minimize additional network requests.
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/atoms/decks.ts | 1 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.test.tsx | 1 | ||||
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 4 | ||||
| -rw-r--r-- | src/client/pages/HomePage.tsx | 23 | ||||
| -rw-r--r-- | src/server/repositories/card.test.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 14 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/decks.test.ts | 59 | ||||
| -rw-r--r-- | src/server/routes/decks.ts | 20 | ||||
| -rw-r--r-- | src/server/routes/study.test.ts | 1 |
11 files changed, 109 insertions, 17 deletions
diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts index 57abef4..e9b0d03 100644 --- a/src/client/atoms/decks.ts +++ b/src/client/atoms/decks.ts @@ -6,6 +6,7 @@ export interface Deck { name: string; description: string | null; newCardsPerDay: number; + dueCardCount: number; createdAt: string; updatedAt: string; } diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 402ecd4..b138a0b 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -70,6 +70,7 @@ const mockDeck = { name: "Japanese Vocabulary", description: "Common Japanese words", newCardsPerDay: 20, + dueCardCount: 0, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", }; diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 4921e22..8946fcf 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -84,6 +84,7 @@ const mockDecks = [ name: "Japanese Vocabulary", description: "Common Japanese words", newCardsPerDay: 20, + dueCardCount: 5, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", }, @@ -92,6 +93,7 @@ const mockDecks = [ name: "Spanish Verbs", description: null, newCardsPerDay: 10, + dueCardCount: 0, createdAt: "2024-01-02T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", }, @@ -239,6 +241,7 @@ describe("HomePage", () => { name: "No Description Deck", description: null, newCardsPerDay: 20, + dueCardCount: 0, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", }; @@ -312,6 +315,7 @@ describe("HomePage", () => { name: "New Deck", description: "A new deck", newCardsPerDay: 20, + dueCardCount: 0, createdAt: "2024-01-03T00:00:00Z", updatedAt: "2024-01-03T00:00:00Z", }; diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index e0e9e9e..ad6ece4 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -57,14 +57,21 @@ function DeckList({ > <div className="flex items-start justify-between gap-4"> <div className="flex-1 min-w-0"> - <Link - href={`/decks/${deck.id}`} - className="block group-hover:text-primary transition-colors" - > - <h3 className="font-display text-lg font-medium text-slate truncate"> - {deck.name} - </h3> - </Link> + <div className="flex items-center gap-3"> + <Link + href={`/decks/${deck.id}`} + className="block group-hover:text-primary transition-colors flex-1 min-w-0" + > + <h3 className="font-display text-lg font-medium text-slate truncate"> + {deck.name} + </h3> + </Link> + {deck.dueCardCount > 0 && ( + <span className="shrink-0 inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 bg-primary text-white text-sm font-medium rounded-full"> + {deck.dueCardCount} + </span> + )} + </div> {deck.description && ( <p className="text-muted text-sm mt-1 line-clamp-2"> {deck.description} diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 0a46a76..b492fd7 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -111,6 +111,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + countDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 04425a2..ac03bc6 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -204,6 +204,20 @@ export const cardRepository: CardRepository = { return result; }, + async countDueCards(deckId: string, now: Date): Promise<number> { + const result = await db + .select({ count: sql<number>`count(*)::int` }) + .from(cards) + .where( + and( + eq(cards.deckId, deckId), + isNull(cards.deletedAt), + lte(cards.due, now), + ), + ); + return result[0]?.count ?? 0; + }, + async findDueCardsWithNoteData( deckId: string, now: Date, diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 4768d49..cb3a287 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -147,6 +147,7 @@ export interface CardRepository { softDelete(id: string, deckId: string): Promise<boolean>; softDeleteByNoteId(noteId: string): Promise<boolean>; findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>; + countDueCards(deckId: string, now: Date): Promise<number>; findDueCardsWithNoteData( deckId: string, now: Date, diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts index 66ba601..e5fb0d4 100644 --- a/src/server/routes/cards.test.ts +++ b/src/server/routes/cards.test.ts @@ -25,6 +25,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + countDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), diff --git a/src/server/routes/decks.test.ts b/src/server/routes/decks.test.ts index 8f5be9d..55aca2d 100644 --- a/src/server/routes/decks.test.ts +++ b/src/server/routes/decks.test.ts @@ -2,7 +2,11 @@ import { Hono } from "hono"; import { sign } from "hono/jwt"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; -import type { Deck, DeckRepository } from "../repositories/index.js"; +import type { + CardRepository, + Deck, + DeckRepository, +} from "../repositories/index.js"; import { createDecksRouter } from "./decks.js"; function createMockDeckRepo(): DeckRepository { @@ -15,6 +19,24 @@ function createMockDeckRepo(): DeckRepository { }; } +function createMockCardRepo(): CardRepository { + return { + findByDeckId: vi.fn(), + findById: vi.fn(), + findByIdWithNoteData: vi.fn(), + findByNoteId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + softDeleteByNoteId: vi.fn(), + findDueCards: vi.fn(), + countDueCards: vi.fn().mockResolvedValue(0), + findDueCardsWithNoteData: vi.fn(), + findDueCardsForStudy: vi.fn(), + updateFSRSFields: vi.fn(), + }; +} + const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; async function createTestToken(userId: string): Promise<string> { @@ -57,12 +79,17 @@ interface DeckResponse { describe("GET /api/decks", () => { let app: Hono; let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; let authToken: string; beforeEach(async () => { vi.clearAllMocks(); mockDeckRepo = createMockDeckRepo(); - const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + mockCardRepo = createMockCardRepo(); + const decksRouter = createDecksRouter({ + deckRepo: mockDeckRepo, + cardRepo: mockCardRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/decks", decksRouter); @@ -112,12 +139,17 @@ describe("GET /api/decks", () => { describe("POST /api/decks", () => { let app: Hono; let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; let authToken: string; beforeEach(async () => { vi.clearAllMocks(); mockDeckRepo = createMockDeckRepo(); - const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + mockCardRepo = createMockCardRepo(); + const decksRouter = createDecksRouter({ + deckRepo: mockDeckRepo, + cardRepo: mockCardRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/decks", decksRouter); @@ -220,12 +252,17 @@ describe("POST /api/decks", () => { describe("GET /api/decks/:id", () => { let app: Hono; let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; let authToken: string; beforeEach(async () => { vi.clearAllMocks(); mockDeckRepo = createMockDeckRepo(); - const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + mockCardRepo = createMockCardRepo(); + const decksRouter = createDecksRouter({ + deckRepo: mockDeckRepo, + cardRepo: mockCardRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/decks", decksRouter); @@ -285,12 +322,17 @@ describe("GET /api/decks/:id", () => { describe("PUT /api/decks/:id", () => { let app: Hono; let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; let authToken: string; beforeEach(async () => { vi.clearAllMocks(); mockDeckRepo = createMockDeckRepo(); - const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + mockCardRepo = createMockCardRepo(); + const decksRouter = createDecksRouter({ + deckRepo: mockDeckRepo, + cardRepo: mockCardRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/decks", decksRouter); @@ -410,12 +452,17 @@ describe("PUT /api/decks/:id", () => { describe("DELETE /api/decks/:id", () => { let app: Hono; let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; let authToken: string; beforeEach(async () => { vi.clearAllMocks(); mockDeckRepo = createMockDeckRepo(); - const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + mockCardRepo = createMockCardRepo(); + const decksRouter = createDecksRouter({ + deckRepo: mockDeckRepo, + cardRepo: mockCardRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/decks", decksRouter); diff --git a/src/server/routes/decks.ts b/src/server/routes/decks.ts index 2450bcd..2e170db 100644 --- a/src/server/routes/decks.ts +++ b/src/server/routes/decks.ts @@ -2,11 +2,17 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js"; -import { type DeckRepository, deckRepository } from "../repositories/index.js"; +import { + type CardRepository, + cardRepository, + type DeckRepository, + deckRepository, +} from "../repositories/index.js"; import { createDeckSchema, updateDeckSchema } from "../schemas/index.js"; export interface DeckDependencies { deckRepo: DeckRepository; + cardRepo: CardRepository; } const deckIdParamSchema = z.object({ @@ -14,14 +20,21 @@ const deckIdParamSchema = z.object({ }); export function createDecksRouter(deps: DeckDependencies) { - const { deckRepo } = deps; + const { deckRepo, cardRepo } = deps; return new Hono() .use("*", authMiddleware) .get("/", async (c) => { const user = getAuthUser(c); const decks = await deckRepo.findByUserId(user.id); - return c.json({ decks }, 200); + const now = new Date(); + const decksWithDueCount = await Promise.all( + decks.map(async (deck) => { + const dueCardCount = await cardRepo.countDueCards(deck.id, now); + return { ...deck, dueCardCount }; + }), + ); + return c.json({ decks: decksWithDueCount }, 200); }) .post("/", zValidator("json", createDeckSchema), async (c) => { const user = getAuthUser(c); @@ -79,4 +92,5 @@ export function createDecksRouter(deps: DeckDependencies) { export const decks = createDecksRouter({ deckRepo: deckRepository, + cardRepo: cardRepository, }); diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts index e2fb457..a5ac817 100644 --- a/src/server/routes/study.test.ts +++ b/src/server/routes/study.test.ts @@ -25,6 +25,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + countDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), |
