review works!

This commit is contained in:
Michael Zhang 2023-06-11 15:08:20 -05:00
parent a1907591c6
commit 561cb49fbf
13 changed files with 266 additions and 91 deletions

View file

@ -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

View file

@ -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)
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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, [<>&#9733;</>, "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"]],
]);

View 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, [<>&#9733;</>, "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
View 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
View 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;
}

View file

@ -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";

View file

@ -17,9 +17,6 @@
padding: 64px 0;
}
.input-box {
}
.incorrect {
background-color: rgb(255, 202, 202) !important;
}

View file

@ -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>

View file

@ -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[];
}