aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-04 22:31:13 +0900
committernsfisis <nsfisis@gmail.com>2026-02-04 22:43:15 +0900
commita047cdd517efe7693ccd41162f9267f48cd67955 (patch)
tree969c6582d53429085c066aa88881d09f42185aca /src/server/routes
parent87d925c8dfb9c0502a739275df19d1dde8b32230 (diff)
downloadkioku-a047cdd517efe7693ccd41162f9267f48cd67955.tar.gz
kioku-a047cdd517efe7693ccd41162f9267f48cd67955.tar.zst
kioku-a047cdd517efe7693ccd41162f9267f48cd67955.zip
feat(study): enforce newCardsPerDay limit in study API
Split due card fetching into new cards and review cards, applying the deck's newCardsPerDay limit to new cards while leaving review cards unrestricted. New cards are placed before review cards in the response. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/server/routes')
-rw-r--r--src/server/routes/cards.test.ts2
-rw-r--r--src/server/routes/decks.test.ts2
-rw-r--r--src/server/routes/study.test.ts104
-rw-r--r--src/server/routes/study.ts16
4 files changed, 117 insertions, 7 deletions
diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts
index e5fb0d4..2179720 100644
--- a/src/server/routes/cards.test.ts
+++ b/src/server/routes/cards.test.ts
@@ -28,6 +28,8 @@ function createMockCardRepo(): CardRepository {
countDueCards: vi.fn(),
findDueCardsWithNoteData: vi.fn(),
findDueCardsForStudy: vi.fn(),
+ findDueNewCardsForStudy: vi.fn(),
+ findDueReviewCardsForStudy: vi.fn(),
updateFSRSFields: vi.fn(),
};
}
diff --git a/src/server/routes/decks.test.ts b/src/server/routes/decks.test.ts
index 55aca2d..d48e494 100644
--- a/src/server/routes/decks.test.ts
+++ b/src/server/routes/decks.test.ts
@@ -33,6 +33,8 @@ function createMockCardRepo(): CardRepository {
countDueCards: vi.fn().mockResolvedValue(0),
findDueCardsWithNoteData: vi.fn(),
findDueCardsForStudy: vi.fn(),
+ findDueNewCardsForStudy: vi.fn(),
+ findDueReviewCardsForStudy: vi.fn(),
updateFSRSFields: vi.fn(),
};
}
diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts
index a5ac817..aeba46b 100644
--- a/src/server/routes/study.test.ts
+++ b/src/server/routes/study.test.ts
@@ -28,6 +28,8 @@ function createMockCardRepo(): CardRepository {
countDueCards: vi.fn(),
findDueCardsWithNoteData: vi.fn(),
findDueCardsForStudy: vi.fn(),
+ findDueNewCardsForStudy: vi.fn(),
+ findDueReviewCardsForStudy: vi.fn(),
updateFSRSFields: vi.fn(),
};
}
@@ -45,6 +47,7 @@ function createMockDeckRepo(): DeckRepository {
function createMockReviewLogRepo(): ReviewLogRepository {
return {
create: vi.fn(),
+ countTodayNewCardReviews: vi.fn().mockResolvedValue(0),
};
}
@@ -170,7 +173,8 @@ describe("GET /api/decks/:deckId/study", () => {
vi.mocked(mockDeckRepo.findById).mockResolvedValue(
createMockDeck({ id: DECK_ID }),
);
- vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue([]);
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue([]);
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue([]);
const res = await app.request(`/api/decks/${DECK_ID}/study`, {
method: "GET",
@@ -184,7 +188,12 @@ describe("GET /api/decks/:deckId/study", () => {
DECK_ID,
"user-uuid-123",
);
- expect(mockCardRepo.findDueCardsForStudy).toHaveBeenCalledWith(
+ expect(mockCardRepo.findDueNewCardsForStudy).toHaveBeenCalledWith(
+ DECK_ID,
+ expect.any(Date),
+ 20,
+ );
+ expect(mockCardRepo.findDueReviewCardsForStudy).toHaveBeenCalledWith(
DECK_ID,
expect.any(Date),
100,
@@ -192,24 +201,31 @@ describe("GET /api/decks/:deckId/study", () => {
});
it("returns due cards", async () => {
- const mockCards = [
+ const newCards = [
createMockCardForStudy({
id: "card-1",
front: "Q1",
back: "A1",
+ state: CardState.New,
fieldValuesMap: {},
}),
+ ];
+ const reviewCards = [
createMockCardForStudy({
id: "card-2",
front: "Q2",
back: "A2",
+ state: CardState.Review,
fieldValuesMap: {},
}),
];
vi.mocked(mockDeckRepo.findById).mockResolvedValue(
createMockDeck({ id: DECK_ID }),
);
- vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue(mockCards);
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue(newCards);
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue(
+ reviewCards,
+ );
const res = await app.request(`/api/decks/${DECK_ID}/study`, {
method: "GET",
@@ -241,7 +257,10 @@ describe("GET /api/decks/:deckId/study", () => {
vi.mocked(mockDeckRepo.findById).mockResolvedValue(
createMockDeck({ id: DECK_ID }),
);
- vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue(mockCards);
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue(
+ mockCards,
+ );
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue([]);
const res = await app.request(`/api/decks/${DECK_ID}/study`, {
method: "GET",
@@ -255,6 +274,81 @@ describe("GET /api/decks/:deckId/study", () => {
expect(body.cards?.[0]?.fieldValuesMap?.Front).toBe("Question");
});
+ it("limits new cards based on newCardsPerDay", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID, newCardsPerDay: 5 }),
+ );
+ vi.mocked(mockReviewLogRepo.countTodayNewCardReviews).mockResolvedValue(3);
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue([]);
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue([]);
+
+ await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(mockCardRepo.findDueNewCardsForStudy).toHaveBeenCalledWith(
+ DECK_ID,
+ expect.any(Date),
+ 2,
+ );
+ });
+
+ it("returns 0 new cards when daily limit is reached", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID, newCardsPerDay: 5 }),
+ );
+ vi.mocked(mockReviewLogRepo.countTodayNewCardReviews).mockResolvedValue(5);
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue([]);
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue([]);
+
+ await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(mockCardRepo.findDueNewCardsForStudy).toHaveBeenCalledWith(
+ DECK_ID,
+ expect.any(Date),
+ 0,
+ );
+ });
+
+ it("places new cards before review cards in response", async () => {
+ const newCards = [
+ createMockCardForStudy({
+ id: "new-1",
+ state: CardState.New,
+ fieldValuesMap: {},
+ }),
+ ];
+ const reviewCards = [
+ createMockCardForStudy({
+ id: "review-1",
+ state: CardState.Review,
+ fieldValuesMap: {},
+ }),
+ ];
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findDueNewCardsForStudy).mockResolvedValue(newCards);
+ vi.mocked(mockCardRepo.findDueReviewCardsForStudy).mockResolvedValue(
+ reviewCards,
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.cards).toHaveLength(2);
+ expect(body.cards?.[0]?.id).toBe("new-1");
+ expect(body.cards?.[1]?.id).toBe("review-1");
+ });
+
it("returns 404 for non-existent deck", async () => {
vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
diff --git a/src/server/routes/study.ts b/src/server/routes/study.ts
index d978a6a..efd450c 100644
--- a/src/server/routes/study.ts
+++ b/src/server/routes/study.ts
@@ -51,9 +51,21 @@ export function createStudyRouter(deps: StudyDependencies) {
}
const now = new Date();
- const dueCards = await cardRepo.findDueCardsForStudy(deckId, now, 100);
- return c.json({ cards: dueCards }, 200);
+ // Calculate new card budget based on today's already-reviewed new cards
+ const reviewedNewCards = await reviewLogRepo.countTodayNewCardReviews(
+ deckId,
+ now,
+ );
+ const newCardBudget = Math.max(0, deck.newCardsPerDay - reviewedNewCards);
+
+ // Fetch new cards (limited) and review cards separately
+ const [newCards, reviewCards] = await Promise.all([
+ cardRepo.findDueNewCardsForStudy(deckId, now, newCardBudget),
+ cardRepo.findDueReviewCardsForStudy(deckId, now, 100),
+ ]);
+
+ return c.json({ cards: [...newCards, ...reviewCards] }, 200);
})
.post(
"/:cardId",