review works!
This commit is contained in:
parent
a1907591c6
commit
561cb49fbf
13 changed files with 266 additions and 91 deletions
|
@ -3,7 +3,10 @@ use std::time::Duration;
|
|||
use sqlx::{Row, SqlitePool};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{kanji::KanjiDb, utils::Ticks};
|
||||
use crate::{
|
||||
kanji::KanjiDb,
|
||||
utils::{Ticks, TICK_MULTIPLIER},
|
||||
};
|
||||
|
||||
pub struct SrsDb(pub SqlitePool);
|
||||
|
||||
|
@ -217,6 +220,8 @@ pub async fn generate_review_batch(
|
|||
pub async fn update_srs_item(
|
||||
srs_db: State<'_, SrsDb>,
|
||||
item_id: u32,
|
||||
delay: i64,
|
||||
new_grade: u32,
|
||||
correct: bool,
|
||||
) -> Result<(), String> {
|
||||
let (success, failure) = match correct {
|
||||
|
@ -224,17 +229,23 @@ pub async fn update_srs_item(
|
|||
false => (0, 1),
|
||||
};
|
||||
|
||||
// Kanji.Interface/ViewModels/Partial/Srs/SrsReviewViewModel.cs:600
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE SrsEntrySet
|
||||
SET
|
||||
SuccessCount = SuccessCount + ?,
|
||||
FailureCount = FailureCount + ?
|
||||
FailureCount = FailureCount + ?,
|
||||
NextAnswerDate = NextAnswerDate + ?,
|
||||
CurrentGrade = ?
|
||||
WHERE ID = ?
|
||||
"#,
|
||||
)
|
||||
.bind(success)
|
||||
.bind(failure)
|
||||
.bind(delay * TICK_MULTIPLIER)
|
||||
.bind(new_grade)
|
||||
.bind(item_id)
|
||||
.execute(&srs_db.0)
|
||||
.await
|
||||
|
|
|
@ -10,6 +10,8 @@ use sqlx::{
|
|||
Decode, Encode, Sqlite, Type,
|
||||
};
|
||||
|
||||
pub const TICK_MULTIPLIER: i64 = 1_000_000_000;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ticks(pub i64);
|
||||
|
||||
|
@ -37,7 +39,7 @@ impl Into<Duration> for Ticks {
|
|||
|
||||
impl From<Duration> for Ticks {
|
||||
fn from(value: Duration) -> Self {
|
||||
Ticks(value.as_secs() as i64 * 1_000_000_000)
|
||||
Ticks(value.as_secs() as i64 * TICK_MULTIPLIER)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
|
|||
srsPart = (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<p>This character is being tracked in SRS!</p>
|
||||
<p>You are learning this item!</p>
|
||||
|
||||
{isValid(nextAnswerDate) && (
|
||||
<p>
|
||||
|
|
|
@ -3,11 +3,11 @@ import classNames from "classnames";
|
|||
import { Link } from "react-router-dom";
|
||||
import { Badge, Grid, GridItem } from "@chakra-ui/layout";
|
||||
import styles from "./KanjiList.module.scss";
|
||||
import { Kanji } from "../types/Kanji";
|
||||
import { Kanji } from "../lib/kanji";
|
||||
import { Input, Spinner } from "@chakra-ui/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import GradeBadge from "./utils/GradeBadge";
|
||||
import LevelBadge from "./utils/LevelBadge";
|
||||
|
||||
export interface KanjiListProps {
|
||||
kanjiList: Kanji[];
|
||||
|
@ -57,6 +57,8 @@ export function KanjiList({
|
|||
|
||||
const renderKanjiItem = (kanji: Kanji, active: boolean) => {
|
||||
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
|
||||
if (kanji.srs_info) console.log("kanji", kanji);
|
||||
|
||||
return (
|
||||
<Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}>
|
||||
<Grid templateRows="repeat(2, 1fr)" templateColumns="auto 1fr" columnGap={4}>
|
||||
|
@ -65,7 +67,7 @@ export function KanjiList({
|
|||
</GridItem>
|
||||
<GridItem>{kanji.meanings[0].meaning}</GridItem>
|
||||
<GridItem className={styles.badges}>
|
||||
<GradeBadge grade={kanji.srs_info?.current_grade} />
|
||||
<LevelBadge grade={kanji.srs_info?.current_grade} />
|
||||
<Badge>#{kanji.most_used_rank} common</Badge>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import { Badge } from "@chakra-ui/react";
|
||||
|
||||
export interface GradeBadgeProps {
|
||||
grade?: number;
|
||||
}
|
||||
|
||||
export default function GradeBadge({ grade }: GradeBadgeProps) {
|
||||
if (!grade) return null;
|
||||
|
||||
const badgeInfo = badgeMap.get(grade);
|
||||
if (!badgeInfo) return null;
|
||||
|
||||
const [letter, colorScheme] = badgeInfo;
|
||||
|
||||
return <Badge colorScheme={colorScheme}>{letter}</Badge>;
|
||||
}
|
||||
|
||||
const badgeMap = new Map<number, [string | JSX.Element, string]>([
|
||||
[8, [<>★</>, "green"]],
|
||||
[7, ["A2", "blue"]],
|
||||
[6, ["A1", "blue"]],
|
||||
[5, ["B2", "yellow"]],
|
||||
[4, ["B1", "yellow"]],
|
||||
[3, ["C2", "orange"]],
|
||||
[2, ["C1", "orange"]],
|
||||
[1, ["D2", "red"]],
|
||||
[0, ["D1", "red"]],
|
||||
]);
|
33
src/components/utils/LevelBadge.tsx
Normal file
33
src/components/utils/LevelBadge.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Badge } from "@chakra-ui/react";
|
||||
import { srsLevels } from "../../lib/srs";
|
||||
|
||||
export interface LevelBadgeProps {
|
||||
grade?: number;
|
||||
}
|
||||
|
||||
export default function LevelBadge({ grade }: LevelBadgeProps) {
|
||||
if (grade == undefined) return null;
|
||||
|
||||
const levelInfo = srsLevels.get(grade);
|
||||
if (!levelInfo) return null;
|
||||
|
||||
const { color, name } = levelInfo;
|
||||
|
||||
return (
|
||||
<Badge backgroundColor={color} color="white">
|
||||
{name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeMap = new Map<number, [string | JSX.Element, string]>([
|
||||
[8, [<>★</>, "green"]],
|
||||
[7, ["A2", "blue"]],
|
||||
[6, ["A1", "blue"]],
|
||||
[5, ["B2", "yellow"]],
|
||||
[4, ["B1", "yellow"]],
|
||||
[3, ["C2", "orange"]],
|
||||
[2, ["C1", "orange"]],
|
||||
[1, ["D2", "red"]],
|
||||
[0, ["D1", "red"]],
|
||||
]);
|
69
src/data/srslevels.json
Normal file
69
src/data/srslevels.json
Normal file
|
@ -0,0 +1,69 @@
|
|||
[
|
||||
{
|
||||
"group": "Set In Stone",
|
||||
"name": "★",
|
||||
"value": 8,
|
||||
"delay": null,
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
|
||||
{
|
||||
"group": "Assimilating",
|
||||
"name": "A2",
|
||||
"value": 7,
|
||||
"delay": 10368000,
|
||||
"color": "#890062"
|
||||
},
|
||||
{
|
||||
"group": "Assimilating",
|
||||
"name": "A1",
|
||||
"value": 6,
|
||||
"delay": 2592000,
|
||||
"color": "#890062"
|
||||
},
|
||||
|
||||
{
|
||||
"group": "Bolstering",
|
||||
"name": "B2",
|
||||
"value": 5,
|
||||
"delay": 1209600,
|
||||
"color": "#5C1696"
|
||||
},
|
||||
{
|
||||
"group": "Bolstering",
|
||||
"name": "B1",
|
||||
"value": 4,
|
||||
"delay": 604800,
|
||||
"color": "#5C1696"
|
||||
},
|
||||
|
||||
{
|
||||
"group": "Committing",
|
||||
"name": "C2",
|
||||
"value": 3,
|
||||
"delay": 259200,
|
||||
"color": "#004E8E"
|
||||
},
|
||||
{
|
||||
"group": "Committing",
|
||||
"name": "C1",
|
||||
"value": 2,
|
||||
"delay": 86400,
|
||||
"color": "#004E8E"
|
||||
},
|
||||
|
||||
{
|
||||
"group": "Discovering",
|
||||
"name": "D2",
|
||||
"value": 1,
|
||||
"delay": 28800,
|
||||
"color": "#1A814D"
|
||||
},
|
||||
{
|
||||
"group": "Discovering",
|
||||
"name": "D1",
|
||||
"value": 0,
|
||||
"delay": 14400,
|
||||
"color": "#1A814D"
|
||||
}
|
||||
]
|
57
src/lib/srs.ts
Normal file
57
src/lib/srs.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
export interface SrsEntry {
|
||||
id: number;
|
||||
associated_kanji: string;
|
||||
current_grade: number;
|
||||
meanings: string[];
|
||||
readings: string[];
|
||||
}
|
||||
|
||||
export interface SrsLevel {
|
||||
group: string;
|
||||
name: string;
|
||||
value: number;
|
||||
/** In seconds */
|
||||
delay: number | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
import srsLevelMap from "../data/srslevels.json";
|
||||
export const srsLevels: Map<number, SrsLevel> = new Map(srsLevelMap.map((v) => [v.value, v]));
|
||||
|
||||
export interface SrsQuestionGroup {
|
||||
srsEntry: SrsEntry;
|
||||
questions: { meaningQuestion: ReviewItem; readingQuestion: ReviewItem };
|
||||
}
|
||||
|
||||
export function allQuestionsAnswered(group: SrsQuestionGroup): boolean {
|
||||
return Object.values(group.questions).every((v) => v.isCorrect != null);
|
||||
}
|
||||
|
||||
export function isGroupCorrect(group: SrsQuestionGroup): boolean {
|
||||
return Object.values(group.questions).every((v) => v.isCorrect == true);
|
||||
}
|
||||
|
||||
export function groupUpdatedLevel(group: SrsQuestionGroup): SrsLevel {
|
||||
const grade = group.srsEntry.current_grade;
|
||||
const modifier = isGroupCorrect(group) ? 1 : -1;
|
||||
|
||||
return (
|
||||
srsLevels.get(grade + modifier) ??
|
||||
// Rip type coercion, but current grade should be pretty much set
|
||||
(srsLevels.get(grade) as SrsLevel)
|
||||
);
|
||||
}
|
||||
|
||||
export enum ReviewItemType {
|
||||
MEANING = "MEANING",
|
||||
READING = "READING",
|
||||
}
|
||||
|
||||
export interface ReviewItem {
|
||||
parent: SrsQuestionGroup;
|
||||
type: ReviewItemType;
|
||||
challenge: string;
|
||||
possibleAnswers: string[];
|
||||
isCorrect: boolean | null;
|
||||
timesRepeated: number;
|
||||
}
|
|
@ -5,7 +5,7 @@ import { Box, Flex, Grid, GridItem, LinkBox, Stack } from "@chakra-ui/layout";
|
|||
import styles from "./KanjiPane.module.scss";
|
||||
import { useParams } from "react-router-dom";
|
||||
import KanjiDisplay from "../components/KanjiDisplay";
|
||||
import { Kanji } from "../types/Kanji";
|
||||
import { Kanji } from "../lib/kanji";
|
||||
import { KanjiList } from "../components/KanjiList";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
|
|
@ -17,9 +17,6 @@
|
|||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
background-color: rgb(255, 202, 202) !important;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
Progress,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
|
@ -11,12 +12,20 @@ import {
|
|||
import styles from "./SrsReviewPane.module.scss";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, ScrollRestoration, useNavigate } from "react-router-dom";
|
||||
import { ArrowBackIcon } from "@chakra-ui/icons";
|
||||
import ConfirmQuitModal from "../components/utils/ConfirmQuitModal";
|
||||
import * as _ from "lodash-es";
|
||||
import { romajiToKana } from "../lib/kanaHelper";
|
||||
import { ReviewItem, ReviewItemType, SrsEntry } from "../types/Srs";
|
||||
import {
|
||||
ReviewItem,
|
||||
ReviewItemType,
|
||||
SrsEntry,
|
||||
SrsQuestionGroup,
|
||||
allQuestionsAnswered,
|
||||
groupUpdatedLevel,
|
||||
isGroupCorrect,
|
||||
} from "../lib/srs";
|
||||
import classNames from "classnames";
|
||||
|
||||
const batchSize = 10;
|
||||
|
@ -37,10 +46,12 @@ function Done() {
|
|||
export function Component() {
|
||||
// null = has not started, (.length == 0) = finished
|
||||
const [reviewQueue, setReviewQueue] = useState<ReviewItem[] | null>(null);
|
||||
const [completedQueue, setCompletedQueue] = useState<ReviewItem[]>([]);
|
||||
|
||||
const [anyProgress, setAnyProgress] = useState(false);
|
||||
const [startingSize, setStartingSize] = useState<number | null>(null);
|
||||
const [currentAnswer, setCurrentAnswer] = useState("");
|
||||
const [isIncorrect, setIsIncorrect] = useState(false);
|
||||
const [incorrectTimes, setIncorrectTimes] = useState(0);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -48,20 +59,36 @@ export function Component() {
|
|||
if (!reviewQueue) {
|
||||
invoke<SrsEntry[]>("generate_review_batch")
|
||||
.then((result) => {
|
||||
const newReviews: ReviewItem[] = result.flatMap((srsEntry) => [
|
||||
{
|
||||
associatedId: srsEntry.id,
|
||||
const newReviews: ReviewItem[] = result.flatMap((srsEntry) => {
|
||||
// L @ breaking type safety, but this is mutually recursive too
|
||||
const srsQuestionGroup: SrsQuestionGroup = {
|
||||
srsEntry,
|
||||
questions: {},
|
||||
} as SrsQuestionGroup;
|
||||
|
||||
const meaningQuestion: ReviewItem = {
|
||||
parent: srsQuestionGroup,
|
||||
type: ReviewItemType.MEANING,
|
||||
challenge: srsEntry.associated_kanji,
|
||||
possibleAnswers: srsEntry.meanings,
|
||||
},
|
||||
{
|
||||
associatedId: srsEntry.id,
|
||||
isCorrect: null,
|
||||
timesRepeated: 0,
|
||||
};
|
||||
|
||||
const readingQuestion: ReviewItem = {
|
||||
parent: srsQuestionGroup,
|
||||
type: ReviewItemType.READING,
|
||||
challenge: srsEntry.associated_kanji,
|
||||
possibleAnswers: srsEntry.readings,
|
||||
},
|
||||
]);
|
||||
isCorrect: null,
|
||||
timesRepeated: 0,
|
||||
};
|
||||
|
||||
srsQuestionGroup.questions.meaningQuestion = meaningQuestion;
|
||||
srsQuestionGroup.questions.readingQuestion = readingQuestion;
|
||||
|
||||
return [meaningQuestion, readingQuestion];
|
||||
});
|
||||
const newReviewsShuffled = _.shuffle(newReviews);
|
||||
|
||||
setReviewQueue(newReviewsShuffled);
|
||||
|
@ -75,7 +102,7 @@ export function Component() {
|
|||
|
||||
if (!reviewQueue) return <Spinner />;
|
||||
if (reviewQueue.length == 0) return <Done />;
|
||||
const nextItem = reviewQueue[0];
|
||||
const [nextItem, ...restOfQueue] = reviewQueue;
|
||||
const possibleAnswers = new Set(nextItem.possibleAnswers);
|
||||
|
||||
const formSubmit = async (evt: FormEvent) => {
|
||||
|
@ -83,28 +110,50 @@ export function Component() {
|
|||
if (!reviewQueue) return;
|
||||
|
||||
const isCorrect = possibleAnswers.has(currentAnswer);
|
||||
nextItem.isCorrect =
|
||||
new Map([
|
||||
[null, isCorrect],
|
||||
[false, false],
|
||||
[true, isCorrect],
|
||||
]).get(nextItem.isCorrect) ?? isCorrect;
|
||||
|
||||
// Update the backend
|
||||
const params = { itemId: nextItem.associatedId, correct: isCorrect };
|
||||
const result = await invoke("update_srs_item", params);
|
||||
console.log("result", result);
|
||||
// Figure out if we need to update the backend
|
||||
if (allQuestionsAnswered(nextItem.parent)) {
|
||||
console.log("SHIET");
|
||||
|
||||
// Check the answer
|
||||
const group = nextItem.parent;
|
||||
const newLevel = groupUpdatedLevel(group);
|
||||
|
||||
const params = {
|
||||
itemId: nextItem.parent.srsEntry.id,
|
||||
correct: isGroupCorrect(nextItem.parent),
|
||||
newGrade: newLevel.value,
|
||||
delay: newLevel.delay,
|
||||
};
|
||||
|
||||
const result = await invoke("update_srs_item", params);
|
||||
console.log("result", result);
|
||||
}
|
||||
|
||||
// If it's wrong this time
|
||||
if (!isCorrect) {
|
||||
setIsIncorrect(true);
|
||||
|
||||
// push it to the back of the queue
|
||||
const lastItem = reviewQueue[reviewQueue.length - 1];
|
||||
if (!_.isEqual(lastItem, nextItem)) setReviewQueue([...reviewQueue, nextItem]);
|
||||
setCurrentAnswer("");
|
||||
setIncorrectTimes(incorrectTimes + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up for next question!
|
||||
setAnyProgress(true);
|
||||
setIsIncorrect(false);
|
||||
setIncorrectTimes(0);
|
||||
setCurrentAnswer("");
|
||||
const [_currentItem, ...rest] = reviewQueue;
|
||||
setReviewQueue(rest);
|
||||
|
||||
if (nextItem.isCorrect || nextItem.timesRepeated > 0) {
|
||||
setCompletedQueue([...completedQueue, nextItem]);
|
||||
setReviewQueue(restOfQueue);
|
||||
} else {
|
||||
nextItem.timesRepeated++;
|
||||
setReviewQueue([...restOfQueue, nextItem]);
|
||||
}
|
||||
};
|
||||
|
||||
const inputBox = (kanaInput: boolean) => {
|
||||
|
@ -115,8 +164,12 @@ export function Component() {
|
|||
setCurrentAnswer(newValue);
|
||||
};
|
||||
|
||||
const className = classNames(styles["input-box"], isIncorrect && styles["incorrect"]);
|
||||
const placeholder = isIncorrect ? "Wrong! Try again..." : "Enter your answer...";
|
||||
const className = classNames(styles["input-box"], incorrectTimes > 0 && styles["incorrect"]);
|
||||
const placeholder =
|
||||
{
|
||||
0: "Enter your answer...",
|
||||
1: "Wrong! Try again...",
|
||||
}[incorrectTimes] || `Answer is: ${nextItem.possibleAnswers.join(", ")}`;
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
|
@ -140,13 +193,16 @@ export function Component() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<p>{JSON.stringify(completedQueue.map((x) => x.challenge))}</p>
|
||||
<p>{JSON.stringify(reviewQueue.map((x) => x.challenge))}</p>
|
||||
|
||||
{startingSize && (
|
||||
<Progress
|
||||
colorScheme="linkedin"
|
||||
hasStripe
|
||||
isAnimated
|
||||
max={startingSize}
|
||||
value={startingSize - reviewQueue.length}
|
||||
value={completedQueue.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -182,11 +238,6 @@ export function Component() {
|
|||
Back
|
||||
</Button>
|
||||
|
||||
<details>
|
||||
<summary>Debug</summary>
|
||||
<pre>{JSON.stringify(nextItem, null, 2)}</pre>
|
||||
</details>
|
||||
|
||||
<div className={styles.container}>{renderInside()}</div>
|
||||
</Container>
|
||||
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
export interface SrsEntry {
|
||||
id: number;
|
||||
associated_kanji: string;
|
||||
current_grade: number;
|
||||
meanings: string[];
|
||||
readings: string[];
|
||||
}
|
||||
|
||||
export enum ReviewItemType {
|
||||
MEANING = "MEANING",
|
||||
READING = "READING",
|
||||
}
|
||||
|
||||
export interface ReviewItem {
|
||||
associatedId: number;
|
||||
type: ReviewItemType;
|
||||
challenge: string;
|
||||
possibleAnswers: string[];
|
||||
}
|
Loading…
Reference in a new issue