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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "このカードを削除する権限がありません。",
|
||||
|
||||
@@ -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": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
|
||||
@@ -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 ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||
|
||||
@@ -103,6 +103,18 @@
|
||||
"resetSuccess": "成功重置 {count} 张卡片",
|
||||
"resetting": "重置中...",
|
||||
"cancel": "取消",
|
||||
"settings": "设置",
|
||||
"settingsTitle": "牌组设置",
|
||||
"newPerDay": "每日新卡数量",
|
||||
"newPerDayHint": "每天最多学习的新卡片数量",
|
||||
"revPerDay": "每日复习数量",
|
||||
"revPerDayHint": "每天最多复习的卡片数量",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"settingsSaved": "设置已保存",
|
||||
"todayNew": "新卡",
|
||||
"todayReview": "复习",
|
||||
"todayLearning": "学习中",
|
||||
"error": {
|
||||
"update": "您没有权限更新此卡片",
|
||||
"delete": "您没有权限删除此卡片",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user