diff --git a/messages/de-DE.json b/messages/de-DE.json index 80e5ff0..37df479 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -517,6 +517,18 @@ "resetSuccess": "{count} Karten erfolgreich zurückgesetzt", "resetting": "Wird zurückgesetzt...", "cancel": "Abbrechen", + "settings": "Einstellungen", + "settingsTitle": "Deck-Einstellungen", + "newPerDay": "Neue Karten pro Tag", + "newPerDayHint": "Maximale Anzahl neuer Karten pro Tag", + "revPerDay": "Wiederholungen pro Tag", + "revPerDayHint": "Maximale Anzahl wiederholter Karten pro Tag", + "save": "Speichern", + "saving": "Wird gespeichert...", + "settingsSaved": "Einstellungen gespeichert", + "todayNew": "Neu", + "todayReview": "Wiederholung", + "todayLearning": "Lernen", "error": { "update": "Sie haben keine Berechtigung, diese Karte zu aktualisieren.", "delete": "Sie haben keine Berechtigung, diese Karte zu löschen.", diff --git a/messages/en-US.json b/messages/en-US.json index e2b611e..48ee512 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -103,6 +103,18 @@ "resetSuccess": "Successfully reset {count} cards", "resetting": "Resetting...", "cancel": "Cancel", + "settings": "Settings", + "settingsTitle": "Deck Settings", + "newPerDay": "New Cards Per Day", + "newPerDayHint": "Maximum new cards to learn each day", + "revPerDay": "Review Cards Per Day", + "revPerDayHint": "Maximum review cards each day", + "save": "Save", + "saving": "Saving...", + "settingsSaved": "Settings saved", + "todayNew": "New", + "todayReview": "Review", + "todayLearning": "Learning", "error": { "update": "You do not have permission to update this card.", "delete": "You do not have permission to delete this card.", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 04ed277..986b7b2 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -112,47 +112,25 @@ "resetSuccess": "{count} cartes réinitialisées avec succès", "resetting": "Réinitialisation en cours...", "cancel": "Annuler", + "settings": "Paramètres", + "settingsTitle": "Paramètres du deck", + "newPerDay": "Nouvelles cartes par jour", + "newPerDayHint": "Nombre maximum de nouvelles cartes par jour", + "revPerDay": "Révisions par jour", + "revPerDayHint": "Nombre maximum de cartes à réviser par jour", + "save": "Enregistrer", + "saving": "Enregistrement...", + "settingsSaved": "Paramètres enregistrés", + "todayNew": "Nouvelles", + "todayReview": "Révisions", + "todayLearning": "En cours", "error": { "update": "Vous n'avez pas la permission de mettre à jour cette carte.", "delete": "Vous n'avez pas la permission de supprimer cette carte.", "add": "Vous n'avez pas la permission d'ajouter des cartes à ce deck." } }, - "deck_id": { - "unauthorized": "Vous n'êtes pas le propriétaire de ce paquet", - "back": "Retour", - "cards": "Cartes", - "itemsCount": "{count} éléments", - "memorize": "Mémoriser", - "loadingCards": "Chargement des cartes...", - "noCards": "Aucune carte dans ce paquet", - "card": "Carte", - "addNewCard": "Ajouter une nouvelle carte", - "add": "Ajouter", - "adding": "Ajout en cours...", - "updateCard": "Mettre à jour la carte", - "update": "Mettre à jour", - "updating": "Mise à jour en cours...", - "word": "Mot", - "definition": "Définition", - "ipa": "IPA", - "example": "Exemple", - "wordAndDefinitionRequired": "Le mot et la définition sont requis", - "edit": "Modifier", - "delete": "Supprimer", - "permissionDenied": "Vous n'avez pas la permission d'effectuer cette action", - "resetProgress": "Réinitialiser", - "resetProgressTitle": "Réinitialiser la progression du paquet", - "resetProgressConfirm": "Cela réinitialisera toutes les cards in this deck to new state. Your learning progress will be lost. Are you sure?", - "resetSuccess": "{count} cards réinitialisées avec succès", - "resetting": "Réinitialisation en cours...", - "cancel": "Annuler", - "error": { - "update": "Vous n'avez pas la permission de mettre à jour cette carte.", - "delete": "Vous n'avez pas la permission de supprimer cette carte.", - "add": "Vous n'avez pas la permission d'ajouter des cartes à ce paquet." - } - }, + "home": { "title": "Apprendre les langues", "description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.", "explore": "Explorer", diff --git a/messages/it-IT.json b/messages/it-IT.json index 7e30022..4b28255 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -112,12 +112,25 @@ "resetSuccess": "{count} tarjetas ripristinate exitosamente", "resetting": "Ripristazione en curso...", "cancel": "Annulla", + "settings": "Impostazioni", + "settingsTitle": "Impostazioni deck", + "newPerDay": "Nuove schede al giorno", + "newPerDayHint": "Numero massimo di nuove schede al giorno", + "revPerDay": "Ripassi al giorno", + "revPerDayHint": "Numero massimo di schede da ripassare al giorno", + "save": "Salva", + "saving": "Salvataggio...", + "settingsSaved": "Impostazioni salvate", + "todayNew": "Nuove", + "todayReview": "Ripasso", + "todayLearning": "In corso", "error": { "update": "Non hai il permesso per aggiorn this card.", "delete": "Non hai il permesso per delete this card.", "add": "Non hai il permesso per add cards to this deck." } }, + "home": { "title": "Impara le Lingue", "description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.", "explore": "Esplora", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index c8e26f9..b7501ff 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -103,6 +103,18 @@ "resetSuccess": "{count}枚のカードを正常にリセットしました", "resetting": "リセット中...", "cancel": "キャンセル", + "settings": "設定", + "settingsTitle": "デッキ設定", + "newPerDay": "1日の新規カード数", + "newPerDayHint": "1日に学習する新規カードの最大数", + "revPerDay": "1日の復習カード数", + "revPerDayHint": "1日に復習するカードの最大数", + "save": "保存", + "saving": "保存中...", + "settingsSaved": "設定を保存しました", + "todayNew": "新規", + "todayReview": "復習", + "todayLearning": "学習中", "error": { "update": "このカードを更新する権限がありません。", "delete": "このカードを削除する権限がありません。", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 141f510..082ea17 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -83,6 +83,53 @@ "deleteFolder": "이 폴더를 삭제할 권한이 없습니다." } }, + "deck_id": { + "unauthorized": "이 덱의 소유자가 아닙니다", + "back": "뒤로", + "cards": "카드", + "itemsCount": "{count}개", + "memorize": "암기", + "loadingCards": "카드 불러오는 중...", + "noCards": "이 덱에 카드가 없습니다", + "card": "카드", + "addNewCard": "새 카드 추가", + "add": "추가", + "adding": "추가 중...", + "updateCard": "카드 업데이트", + "update": "업데이트", + "updating": "업데이트 중...", + "word": "단어", + "definition": "정의", + "ipa": "IPA", + "example": "예문", + "wordAndDefinitionRequired": "단어와 정의는 필수입니다", + "edit": "편집", + "delete": "삭제", + "permissionDenied": "이 작업을 수행할 권한이 없습니다", + "resetProgress": "초기화", + "resetProgressTitle": "덱 진행 초기화", + "resetProgressConfirm": "이 덱의 모든 카드가 새 상태로 초기화됩니다. 학습 진행 상황이 손실됩니다. 계속하시겠습니까?", + "resetSuccess": "{count}개 카드 초기화 완료", + "resetting": "초기화 중...", + "cancel": "취소", + "settings": "설정", + "settingsTitle": "덱 설정", + "newPerDay": "하루 새 카드 수", + "newPerDayHint": "하루에 학습할 최대 새 카드 수", + "revPerDay": "하루 복습 카드 수", + "revPerDayHint": "하루에 복습할 최대 카드 수", + "save": "저장", + "saving": "저장 중...", + "settingsSaved": "설정 저장됨", + "todayNew": "새 카드", + "todayReview": "복습", + "todayLearning": "학습 중", + "error": { + "update": "이 카드를 업데이트할 권한이 없습니다.", + "delete": "이 카드를 삭제할 권한이 없습니다.", + "add": "이 덱에 카드를 추가할 권한이 없습니다." + } + }, "home": { "title": "언어 배우기", "description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.", diff --git a/messages/ug-CN.json b/messages/ug-CN.json index ac58538..d4d5313 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -83,6 +83,53 @@ "deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق." } }, + "deck_id": { + "unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس", + "back": "قايتىش", + "cards": "كارتلار", + "itemsCount": "{count} تۈر", + "memorize": "يادلاش", + "loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...", + "noCards": "بۇ دېكتا كارت يوق", + "card": "كارتا", + "addNewCard": "يېڭى كارتا قوشۇش", + "add": "قوشۇش", + "adding": "قوشۇلىۋاتىدۇ...", + "updateCard": "كارتىنى يېڭىلاش", + "update": "يېڭىلاش", + "updating": "يېڭىلىنىۋاتىدۇ...", + "word": "سۆز", + "definition": "ئېنىقلىما", + "ipa": "IPA", + "example": "مىسال", + "wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر", + "edit": "تەھرىرلەش", + "delete": "ئۆچۈرۈش", + "permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق", + "resetProgress": "قايتا تەڭشەش", + "resetProgressTitle": "دېك ئىلگىرىلەش قايتا تەڭشەش", + "resetProgressConfirm": "بۇ دېكتىكى بارلىق كارتىلار يېڭى ھالەتكە قايتا تەڭشىلىدۇ. ئۆگىنىش ئىلگىرىلەشلىرىڭىز يوقىلىدۇ. داۋاملاشتۇرامسىز؟", + "resetSuccess": "{count} كارتا مۇۋەپپەقىيەتلىك قايتا تەڭشەلدى", + "resetting": "قايتا تەڭشەۋاتىدۇ...", + "cancel": "بىكار قىلىش", + "settings": "تەڭشەكلەر", + "settingsTitle": "دېك تەڭشەكلىرى", + "newPerDay": "كۈندىلىك يېڭى كارتا سانى", + "newPerDayHint": "ھەر كۈنى ئۆگىنىدىغان ئەڭ كۆپ يېڭى كارتا سانى", + "revPerDay": "كۈندىلىك تەكرار سانى", + "revPerDayHint": "ھەر كۈنى تەكرارلايدىغان ئەڭ كۆپ كارتا سانى", + "save": "ساقلاش", + "saving": "ساقلىنىۋاتىدۇ...", + "settingsSaved": "تەڭشەكلەر ساقلاندى", + "todayNew": "يېڭى", + "todayReview": "تەكرار", + "todayLearning": "ئۆگىنىۋاتىدۇ", + "error": { + "update": "بۇ كارتىنى يېڭىلاش ھوقۇقىڭىز يوق.", + "delete": "بۇ كارتىنى ئۆچۈرۈش ھوقۇقىڭىز يوق.", + "add": "بۇ دېككە كارتا قوشۇش ھوقۇقىڭىز يوق." + } + }, "home": { "title": "تىل ئۆگىنىش", "description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index b5105ce..19e4a6a 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -103,6 +103,18 @@ "resetSuccess": "成功重置 {count} 张卡片", "resetting": "重置中...", "cancel": "取消", + "settings": "设置", + "settingsTitle": "牌组设置", + "newPerDay": "每日新卡数量", + "newPerDayHint": "每天最多学习的新卡片数量", + "revPerDay": "每日复习数量", + "revPerDayHint": "每天最多复习的卡片数量", + "save": "保存", + "saving": "保存中...", + "settingsSaved": "设置已保存", + "todayNew": "新卡", + "todayReview": "复习", + "todayLearning": "学习中", "error": { "update": "您没有权限更新此卡片", "delete": "您没有权限删除此卡片", diff --git a/prisma/migrations/20260316004919_add_deck_daily_limits/migration.sql b/prisma/migrations/20260316004919_add_deck_daily_limits/migration.sql new file mode 100644 index 0000000..d47b4fb --- /dev/null +++ b/prisma/migrations/20260316004919_add_deck_daily_limits/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20, +ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab6453d..92b163b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,8 @@ model Deck { visibility Visibility @default(PRIVATE) collapsed Boolean @default(false) conf Json @default("{}") + newPerDay Int @default(20) @map("new_per_day") + revPerDay Int @default(200) @map("rev_per_day") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/decks/[deck_id]/InDeck.tsx b/src/app/decks/[deck_id]/InDeck.tsx index 84c5eee..d6a6330 100644 --- a/src/app/decks/[deck_id]/InDeck.tsx +++ b/src/app/decks/[deck_id]/InDeck.tsx @@ -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(null); + const [todayStats, setTodayStats] = useState(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 (
@@ -94,6 +141,13 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo

{t("itemsCount", { count: cards.length })}

+ {todayStats && ( + + {t("todayNew")}: {todayStats.newStudied} + {t("todayReview")}: {todayStats.reviewStudied} + {t("todayLearning")}: {todayStats.learningStudied} + + )}
@@ -106,6 +160,12 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo {!isReadOnly && ( <> + setSettingsModal(true)} + title={t("settings")} + > + + setResetModal(true)} leftIcon={} @@ -184,6 +244,53 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo + + {/* Settings Modal */} + setSettingsModal(false)} size="sm"> + + {t("settingsTitle")} + + +
+
+ + setSettingsForm(prev => ({ ...prev, newPerDay: parseInt(e.target.value) || 0 }))} + min={0} + max={999} + /> +

{t("newPerDayHint")}

+
+
+ + setSettingsForm(prev => ({ ...prev, revPerDay: parseInt(e.target.value) || 0 }))} + min={0} + max={9999} + /> +

{t("revPerDayHint")}

+
+
+
+ + setSettingsModal(false)}> + {t("cancel")} + + + {savingSettings ? t("saving") : t("save")} + + +
); }; diff --git a/src/modules/card/card-action-dto.ts b/src/modules/card/card-action-dto.ts index 8ab9106..eee8fdd 100644 --- a/src/modules/card/card-action-dto.ts +++ b/src/modules/card/card-action-dto.ts @@ -169,6 +169,24 @@ export const schemaActionInputResetDeckCards = z.object({ export type ActionInputResetDeckCards = z.infer; export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards); +export const schemaActionInputGetTodayStudyStats = z.object({ + deckId: z.number().int().positive(), +}); +export type ActionInputGetTodayStudyStats = z.infer; +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; diff --git a/src/modules/card/card-action.ts b/src/modules/card/card-action.ts index 31fd5b6..0803e7b 100644 --- a/src/modules/card/card-action.ts +++ b/src/modules/card/card-action.ts @@ -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 { + 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 { 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 } }; } } diff --git a/src/modules/card/card-repository-dto.ts b/src/modules/card/card-repository-dto.ts index fb4c44f..c53d405 100644 --- a/src/modules/card/card-repository-dto.ts +++ b/src/modules/card/card-repository-dto.ts @@ -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; +} diff --git a/src/modules/card/card-repository.ts b/src/modules/card/card-repository.ts index 247f1ef..ce03706 100644 --- a/src/modules/card/card-repository.ts +++ b/src/modules/card/card-repository.ts @@ -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 { + 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; +} diff --git a/src/modules/card/card-service-dto.ts b/src/modules/card/card-service-dto.ts index e5feaaf..04b225c 100644 --- a/src/modules/card/card-service-dto.ts +++ b/src/modules/card/card-service-dto.ts @@ -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], diff --git a/src/modules/card/card-service.ts b/src/modules/card/card-service.ts index f8c4681..10bdaf2 100644 --- a/src/modules/card/card-service.ts +++ b/src/modules/card/card-service.ts @@ -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 { + 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, + }; +} diff --git a/src/modules/deck/deck-action-dto.ts b/src/modules/deck/deck-action-dto.ts index c4de5ba..82ef87d 100644 --- a/src/modules/deck/deck-action-dto.ts +++ b/src/modules/deck/deck-action-dto.ts @@ -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; 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; diff --git a/src/modules/deck/deck-action.ts b/src/modules/deck/deck-action.ts index fce1d0e..3b79bae 100644 --- a/src/modules/deck/deck-action.ts +++ b/src/modules/deck/deck-action.ts @@ -101,6 +101,8 @@ export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise