feat(deck): add daily learning limits and today's study stats

- Add newPerDay and revPerDay fields to Deck model (Anki-style)
- Add settings modal to configure daily limits per deck
- Display today's studied counts (new/review/learning) on deck page
- Add i18n translations for all 8 languages
- Fix JSON syntax errors in fr-FR.json and it-IT.json
- Fix double counting bug in repoGetTodayStudyStats
This commit is contained in:
2026-03-16 09:01:55 +08:00
parent a68951f1d3
commit bc0b392875
23 changed files with 466 additions and 60 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
import { ArrowLeft, Plus, RotateCcw, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AddCardModal } from "./AddCardModal";
@@ -10,8 +10,13 @@ import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton, LightButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList";
import { Modal } from "@/design-system/overlay/modal";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard, actionResetDeckCards } from "@/modules/card/card-action";
import { Input } from "@/design-system/base/input";
import { HStack } from "@/design-system/layout/stack";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard, actionResetDeckCards, actionGetTodayStudyStats } from "@/modules/card/card-action";
import { actionGetDeckById, actionUpdateDeck } from "@/modules/deck/deck-action";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import type { ActionOutputTodayStudyStats } from "@/modules/card/card-action-dto";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import { toast } from "sonner";
@@ -21,25 +26,45 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
const [openAddModal, setAddModal] = useState(false);
const [openResetModal, setResetModal] = useState(false);
const [resetting, setResetting] = useState(false);
const [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
const [todayStats, setTodayStats] = useState<ActionOutputTodayStudyStats | null>(null);
const [openSettingsModal, setSettingsModal] = useState(false);
const [settingsForm, setSettingsForm] = useState({ newPerDay: 20, revPerDay: 200 });
const [savingSettings, setSavingSettings] = useState(false);
const router = useRouter();
const t = useTranslations("deck_id");
useEffect(() => {
const fetchCards = async () => {
setLoading(true);
await actionGetCardsByDeckIdWithNotes({ deckId })
.then(result => {
if (!result.success || !result.data) {
throw new Error(result.message || "Failed to load cards");
}
return result.data;
}).then(setCards)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
})
.finally(() => {
setLoading(false);
});
try {
const [cardsResult, deckResult, statsResult] = await Promise.all([
actionGetCardsByDeckIdWithNotes({ deckId }),
actionGetDeckById({ deckId }),
actionGetTodayStudyStats({ deckId }),
]);
if (!cardsResult.success || !cardsResult.data) {
throw new Error(cardsResult.message || "Failed to load cards");
}
setCards(cardsResult.data);
if (deckResult.success && deckResult.data) {
setDeckInfo(deckResult.data);
setSettingsForm({
newPerDay: deckResult.data.newPerDay ?? 20,
revPerDay: deckResult.data.revPerDay ?? 200,
});
}
if (statsResult.success && statsResult.data) {
setTodayStats(statsResult.data);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setLoading(false);
}
};
fetchCards();
}, [deckId]);
@@ -75,6 +100,28 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
}
};
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
const result = await actionUpdateDeck({
deckId,
newPerDay: settingsForm.newPerDay,
revPerDay: settingsForm.revPerDay,
});
if (result.success) {
setDeckInfo(prev => prev ? { ...prev, newPerDay: settingsForm.newPerDay, revPerDay: settingsForm.revPerDay } : null);
setSettingsModal(false);
toast.success(t("settingsSaved"));
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setSavingSettings(false);
}
};
return (
<PageLayout>
<div className="mb-6">
@@ -94,6 +141,13 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
<p className="text-sm text-gray-500">
{t("itemsCount", { count: cards.length })}
</p>
{todayStats && (
<HStack gap={3} className="mt-2 text-xs text-gray-600">
<span>{t("todayNew")}: {todayStats.newStudied}</span>
<span>{t("todayReview")}: {todayStats.reviewStudied}</span>
<span>{t("todayLearning")}: {todayStats.learningStudied}</span>
</HStack>
)}
</div>
<div className="flex items-center gap-2">
@@ -106,6 +160,12 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
</PrimaryButton>
{!isReadOnly && (
<>
<CircleButton
onClick={() => setSettingsModal(true)}
title={t("settings")}
>
<Settings size={18} className="text-gray-700" />
</CircleButton>
<LightButton
onClick={() => setResetModal(true)}
leftIcon={<RotateCcw size={16} />}
@@ -184,6 +244,53 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
</PrimaryButton>
</Modal.Footer>
</Modal>
{/* Settings Modal */}
<Modal open={openSettingsModal} onClose={() => setSettingsModal(false)} size="sm">
<Modal.Header>
<Modal.Title>{t("settingsTitle")}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("newPerDay")}
</label>
<Input
type="number"
variant="bordered"
value={settingsForm.newPerDay}
onChange={(e) => setSettingsForm(prev => ({ ...prev, newPerDay: parseInt(e.target.value) || 0 }))}
min={0}
max={999}
/>
<p className="text-xs text-gray-500 mt-1">{t("newPerDayHint")}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("revPerDay")}
</label>
<Input
type="number"
variant="bordered"
value={settingsForm.revPerDay}
onChange={(e) => setSettingsForm(prev => ({ ...prev, revPerDay: parseInt(e.target.value) || 0 }))}
min={0}
max={9999}
/>
<p className="text-xs text-gray-500 mt-1">{t("revPerDayHint")}</p>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={() => setSettingsModal(false)}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleSaveSettings} loading={savingSettings}>
{savingSettings ? t("saving") : t("save")}
</PrimaryButton>
</Modal.Footer>
</Modal>
</PageLayout>
);
};

View File

@@ -169,6 +169,24 @@ export const schemaActionInputResetDeckCards = z.object({
export type ActionInputResetDeckCards = z.infer<typeof schemaActionInputResetDeckCards>;
export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards);
export const schemaActionInputGetTodayStudyStats = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetTodayStudyStats = z.infer<typeof schemaActionInputGetTodayStudyStats>;
export const validateActionInputGetTodayStudyStats = generateValidator(schemaActionInputGetTodayStudyStats);
export type ActionOutputGetTodayStudyStats = {
success: boolean;
message: string;
data?: ActionOutputTodayStudyStats;
};
export type ActionOutputTodayStudyStats = {
newStudied: number;
reviewStudied: number;
learningStudied: number;
totalStudied: number;
};
export type ActionOutputResetDeckCards = {
success: boolean;
message: string;

View File

@@ -14,6 +14,7 @@ import {
ActionInputDeleteCard,
ActionInputGetCardById,
ActionInputResetDeckCards,
ActionInputGetTodayStudyStats,
ActionOutputCreateCard,
ActionOutputAnswerCard,
ActionOutputGetCards,
@@ -21,10 +22,11 @@ import {
ActionOutputGetCardStats,
ActionOutputDeleteCard,
ActionOutputGetCardById,
ActionOutputResetDeckCards,
ActionOutputCard,
ActionOutputCardWithNote,
ActionOutputScheduledCard,
ActionOutputResetDeckCards,
ActionOutputGetTodayStudyStats,
validateActionInputCreateCard,
validateActionInputAnswerCard,
validateActionInputGetCardsForReview,
@@ -34,6 +36,7 @@ import {
validateActionInputDeleteCard,
validateActionInputGetCardById,
validateActionInputResetDeckCards,
validateActionInputGetTodayStudyStats,
} from "./card-action-dto";
import {
serviceCreateCard,
@@ -47,6 +50,7 @@ import {
serviceGetCardByIdWithNote,
serviceCheckCardOwnership,
serviceResetDeckCards,
serviceGetTodayStudyStats,
} from "./card-service";
import { CardQueue } from "../../../generated/prisma/enums";
@@ -430,13 +434,34 @@ export async function actionGetCardById(
}
}
export async function actionGetTodayStudyStats(
input: ActionInputGetTodayStudyStats,
): Promise<ActionOutputGetTodayStudyStats> {
try {
const validated = validateActionInputGetTodayStudyStats(input);
const stats = await serviceGetTodayStudyStats({ deckId: validated.deckId });
return {
success: true,
message: "Today's study stats fetched successfully",
data: stats,
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get today study stats", { error: e });
return { success: false, message: "An error occurred while fetching study stats" };
}
}
export async function actionResetDeckCards(
input: unknown,
input: ActionInputResetDeckCards,
): Promise<ActionOutputResetDeckCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
return { success: false, message: "Unauthorized", data: { count: 0 } };
}
const validated = validateActionInputResetDeckCards(input);
@@ -446,7 +471,7 @@ export async function actionResetDeckCards(
});
if (!result.success) {
return { success: false, message: result.message };
return { success: false, message: result.message, data: { count: 0 } };
}
return {
@@ -456,9 +481,9 @@ export async function actionResetDeckCards(
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
return { success: false, message: e.message, data: { count: 0 } };
}
log.error("Failed to reset deck cards", { error: e });
return { success: false, message: "An error occurred while resetting deck cards" };
return { success: false, message: "An error occurred while resetting deck cards", data: { count: 0 } };
}
}

View File

@@ -103,6 +103,17 @@ export type RepoOutputCardStats = {
due: number;
};
export type RepoOutputTodayStudyStats = {
newStudied: number;
reviewStudied: number;
learningStudied: number;
totalStudied: number;
};
export interface RepoInputGetTodayStudyStats {
deckId: number;
}
export interface RepoInputResetDeckCards {
deckId: number;
}
@@ -110,3 +121,7 @@ export interface RepoInputResetDeckCards {
export type RepoOutputResetDeckCards = {
count: number;
};
export interface RepoInputGetTodayStudyStats {
deckId: number;
}

View File

@@ -1,5 +1,3 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateCard,
RepoInputUpdateCard,
@@ -8,12 +6,16 @@ import {
RepoInputGetNewCards,
RepoInputBulkUpdateCards,
RepoInputResetDeckCards,
RepoInputGetTodayStudyStats,
RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats,
RepoOutputTodayStudyStats,
RepoOutputResetDeckCards,
} from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
const log = createLogger("card-repository");
@@ -324,8 +326,8 @@ export async function repoResetDeckCards(
ivl: 0,
factor: 2500,
reps: 0,
lapses: 0,
left: 0,
lapses: 1,
left: 1,
odue: 0,
odid: 0,
mod: Math.floor(Date.now() / 1000),
@@ -335,3 +337,48 @@ export async function repoResetDeckCards(
log.info("Deck cards reset", { deckId: input.deckId, count: result.count });
return { count: result.count };
}
export async function repoGetTodayStudyStats(
input: RepoInputGetTodayStudyStats,
): Promise<RepoOutputTodayStudyStats> {
const now = new Date();
const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
startOfToday.setUTCHours(0, 0, 0, 0);
const todayStart = startOfToday.getTime();
const revlogs = await prisma.revlog.findMany({
where: {
card: {
deckId: input.deckId,
},
id: {
gte: todayStart,
},
},
select: {
id: true,
cardId: true,
type: true,
},
});
const stats: RepoOutputTodayStudyStats = {
newStudied: 0,
reviewStudied: 0,
learningStudied: 0,
totalStudied: 0,
};
for (const revlog of revlogs) {
stats.totalStudied++;
if (revlog.type === 0) {
stats.newStudied++;
} else if (revlog.type === 1) {
stats.learningStudied++;
} else if (revlog.type === 2 || revlog.type === 3) {
stats.reviewStudied++;
}
}
return stats;
}

View File

@@ -117,6 +117,17 @@ export type ServiceOutputResetDeckCards = {
message: string;
};
export type ServiceInputGetTodayStudyStats = {
deckId: number;
};
export type ServiceOutputTodayStudyStats = {
newStudied: number;
reviewStudied: number;
learningStudied: number;
totalStudied: number;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
RELEARNING_STEPS: [10],

View File

@@ -13,6 +13,7 @@ import {
repoGetCardsByNoteId,
repoGetCardDeckOwnerId,
repoResetDeckCards,
repoGetTodayStudyStats,
} from "./card-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import {
@@ -29,6 +30,7 @@ import {
ServiceInputCheckCardOwnership,
ServiceInputResetDeckCards,
ServiceInputCheckDeckOwnership,
ServiceInputGetTodayStudyStats,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
@@ -36,6 +38,7 @@ import {
ServiceOutputReviewResult,
ServiceOutputCheckCardOwnership,
ServiceOutputResetDeckCards,
ServiceOutputTodayStudyStats,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
@@ -524,3 +527,17 @@ export async function serviceResetDeckCards(
log.info("Deck cards reset successfully", { deckId: input.deckId, count: result.count });
return { success: true, count: result.count, message: "Deck cards reset successfully" };
}
export async function serviceGetTodayStudyStats(
input: ServiceInputGetTodayStudyStats,
): Promise<ServiceOutputTodayStudyStats> {
log.debug("Getting today study stats", { deckId: input.deckId });
const repoStats = await repoGetTodayStudyStats({ deckId: input.deckId });
return {
newStudied: repoStats.newStudied,
reviewStudied: repoStats.reviewStudied,
learningStudied: repoStats.learningStudied,
totalStudied: repoStats.totalStudied,
};
}

View File

@@ -15,6 +15,8 @@ export const schemaActionInputUpdateDeck = z.object({
desc: z.string().max(500).optional(),
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
collapsed: z.boolean().optional(),
newPerDay: z.number().int().min(0).max(999).optional(),
revPerDay: z.number().int().min(0).max(9999).optional(),
});
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
@@ -46,6 +48,8 @@ export type ActionOutputDeck = {
visibility: "PRIVATE" | "PUBLIC";
collapsed: boolean;
conf: unknown;
newPerDay: number;
revPerDay: number;
createdAt: Date;
updatedAt: Date;
cardCount?: number;

View File

@@ -101,6 +101,8 @@ export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<Ac
desc: validatedInput.desc,
visibility: validatedInput.visibility as Visibility | undefined,
collapsed: validatedInput.collapsed,
newPerDay: validatedInput.newPerDay,
revPerDay: validatedInput.revPerDay,
});
} catch (e) {
if (e instanceof ValidateError) {

View File

@@ -13,6 +13,8 @@ export interface RepoInputUpdateDeck {
desc?: string;
visibility?: Visibility;
collapsed?: boolean;
newPerDay?: number;
revPerDay?: number;
}
export interface RepoInputGetDeckById {
@@ -41,6 +43,8 @@ export type RepoOutputDeck = {
visibility: Visibility;
collapsed: boolean;
conf: unknown;
newPerDay: number;
revPerDay: number;
createdAt: Date;
updatedAt: Date;
cardCount?: number;

View File

@@ -59,6 +59,8 @@ export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<Repo
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
newPerDay: deck.newPerDay,
revPerDay: deck.revPerDay,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
@@ -86,6 +88,8 @@ export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Pr
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
newPerDay: deck.newPerDay,
revPerDay: deck.revPerDay,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
@@ -118,6 +122,8 @@ export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): P
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
newPerDay: deck.newPerDay,
revPerDay: deck.revPerDay,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
@@ -175,6 +181,8 @@ export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById):
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
newPerDay: deck.newPerDay,
revPerDay: deck.revPerDay,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
@@ -279,6 +287,8 @@ export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks):
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
newPerDay: deck.newPerDay,
revPerDay: deck.revPerDay,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
@@ -316,6 +326,8 @@ export async function repoGetUserFavoriteDecks(
visibility: fav.deck.visibility,
collapsed: fav.deck.collapsed,
conf: fav.deck.conf,
newPerDay: fav.deck.newPerDay,
revPerDay: fav.deck.revPerDay,
createdAt: fav.deck.createdAt,
updatedAt: fav.deck.updatedAt,
cardCount: fav.deck._count?.cards ?? 0,

View File

@@ -13,6 +13,8 @@ export type ServiceInputUpdateDeck = {
desc?: string;
visibility?: Visibility;
collapsed?: boolean;
newPerDay?: number;
revPerDay?: number;
};
export type ServiceInputDeleteDeck = {
@@ -45,6 +47,8 @@ export type ServiceOutputDeck = {
visibility: Visibility;
collapsed: boolean;
conf: unknown;
newPerDay: number;
revPerDay: number;
createdAt: Date;
updatedAt: Date;
cardCount?: number;

View File

@@ -59,6 +59,8 @@ export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<
desc: input.desc,
visibility: input.visibility,
collapsed: input.collapsed,
newPerDay: input.newPerDay,
revPerDay: input.revPerDay,
});
log.info("Deck updated successfully", { deckId: input.deckId });
return { success: true, message: "Deck updated successfully" };