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 sqlx::{Row, SqlitePool};
use tauri::State; use tauri::State;
use crate::{kanji::KanjiDb, utils::Ticks}; use crate::{
kanji::KanjiDb,
utils::{Ticks, TICK_MULTIPLIER},
};
pub struct SrsDb(pub SqlitePool); pub struct SrsDb(pub SqlitePool);
@ -217,6 +220,8 @@ pub async fn generate_review_batch(
pub async fn update_srs_item( pub async fn update_srs_item(
srs_db: State<'_, SrsDb>, srs_db: State<'_, SrsDb>,
item_id: u32, item_id: u32,
delay: i64,
new_grade: u32,
correct: bool, correct: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let (success, failure) = match correct { let (success, failure) = match correct {
@ -224,17 +229,23 @@ pub async fn update_srs_item(
false => (0, 1), false => (0, 1),
}; };
// Kanji.Interface/ViewModels/Partial/Srs/SrsReviewViewModel.cs:600
sqlx::query( sqlx::query(
r#" r#"
UPDATE SrsEntrySet UPDATE SrsEntrySet
SET SET
SuccessCount = SuccessCount + ?, SuccessCount = SuccessCount + ?,
FailureCount = FailureCount + ? FailureCount = FailureCount + ?,
NextAnswerDate = NextAnswerDate + ?,
CurrentGrade = ?
WHERE ID = ? WHERE ID = ?
"#, "#,
) )
.bind(success) .bind(success)
.bind(failure) .bind(failure)
.bind(delay * TICK_MULTIPLIER)
.bind(new_grade)
.bind(item_id) .bind(item_id)
.execute(&srs_db.0) .execute(&srs_db.0)
.await .await

View file

@ -10,6 +10,8 @@ use sqlx::{
Decode, Encode, Sqlite, Type, Decode, Encode, Sqlite, Type,
}; };
pub const TICK_MULTIPLIER: i64 = 1_000_000_000;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Ticks(pub i64); pub struct Ticks(pub i64);
@ -37,7 +39,7 @@ impl Into<Duration> for Ticks {
impl From<Duration> for Ticks { impl From<Duration> for Ticks {
fn from(value: Duration) -> Self { 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 = ( srsPart = (
<Alert status="info"> <Alert status="info">
<AlertIcon /> <AlertIcon />
<p>This character is being tracked in SRS!</p> <p>You are learning this item!</p>
{isValid(nextAnswerDate) && ( {isValid(nextAnswerDate) && (
<p> <p>

View file

@ -3,11 +3,11 @@ import classNames from "classnames";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Badge, Grid, GridItem } from "@chakra-ui/layout"; import { Badge, Grid, GridItem } from "@chakra-ui/layout";
import styles from "./KanjiList.module.scss"; import styles from "./KanjiList.module.scss";
import { Kanji } from "../types/Kanji"; import { Kanji } from "../lib/kanji";
import { Input, Spinner } from "@chakra-ui/react"; import { Input, Spinner } from "@chakra-ui/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import GradeBadge from "./utils/GradeBadge"; import LevelBadge from "./utils/LevelBadge";
export interface KanjiListProps { export interface KanjiListProps {
kanjiList: Kanji[]; kanjiList: Kanji[];
@ -57,6 +57,8 @@ export function KanjiList({
const renderKanjiItem = (kanji: Kanji, active: boolean) => { const renderKanjiItem = (kanji: Kanji, active: boolean) => {
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]); const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
if (kanji.srs_info) console.log("kanji", kanji);
return ( return (
<Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}> <Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}>
<Grid templateRows="repeat(2, 1fr)" templateColumns="auto 1fr" columnGap={4}> <Grid templateRows="repeat(2, 1fr)" templateColumns="auto 1fr" columnGap={4}>
@ -65,7 +67,7 @@ export function KanjiList({
</GridItem> </GridItem>
<GridItem>{kanji.meanings[0].meaning}</GridItem> <GridItem>{kanji.meanings[0].meaning}</GridItem>
<GridItem className={styles.badges}> <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> <Badge>#{kanji.most_used_rank} common</Badge>
</GridItem> </GridItem>
</Grid> </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 styles from "./KanjiPane.module.scss";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import KanjiDisplay from "../components/KanjiDisplay"; import KanjiDisplay from "../components/KanjiDisplay";
import { Kanji } from "../types/Kanji"; import { Kanji } from "../lib/kanji";
import { KanjiList } from "../components/KanjiList"; import { KanjiList } from "../components/KanjiList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";

View file

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

View file

@ -4,6 +4,7 @@ import {
Input, Input,
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
InputRightElement,
Progress, Progress,
Spinner, Spinner,
useDisclosure, useDisclosure,
@ -11,12 +12,20 @@ import {
import styles from "./SrsReviewPane.module.scss"; import styles from "./SrsReviewPane.module.scss";
import { ChangeEvent, FormEvent, useEffect, useState } from "react"; import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/tauri"; 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 { ArrowBackIcon } from "@chakra-ui/icons";
import ConfirmQuitModal from "../components/utils/ConfirmQuitModal"; import ConfirmQuitModal from "../components/utils/ConfirmQuitModal";
import * as _ from "lodash-es"; import * as _ from "lodash-es";
import { romajiToKana } from "../lib/kanaHelper"; 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"; import classNames from "classnames";
const batchSize = 10; const batchSize = 10;
@ -37,10 +46,12 @@ function Done() {
export function Component() { export function Component() {
// null = has not started, (.length == 0) = finished // null = has not started, (.length == 0) = finished
const [reviewQueue, setReviewQueue] = useState<ReviewItem[] | null>(null); const [reviewQueue, setReviewQueue] = useState<ReviewItem[] | null>(null);
const [completedQueue, setCompletedQueue] = useState<ReviewItem[]>([]);
const [anyProgress, setAnyProgress] = useState(false); const [anyProgress, setAnyProgress] = useState(false);
const [startingSize, setStartingSize] = useState<number | null>(null); const [startingSize, setStartingSize] = useState<number | null>(null);
const [currentAnswer, setCurrentAnswer] = useState(""); const [currentAnswer, setCurrentAnswer] = useState("");
const [isIncorrect, setIsIncorrect] = useState(false); const [incorrectTimes, setIncorrectTimes] = useState(0);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const navigate = useNavigate(); const navigate = useNavigate();
@ -48,20 +59,36 @@ export function Component() {
if (!reviewQueue) { if (!reviewQueue) {
invoke<SrsEntry[]>("generate_review_batch") invoke<SrsEntry[]>("generate_review_batch")
.then((result) => { .then((result) => {
const newReviews: ReviewItem[] = result.flatMap((srsEntry) => [ const newReviews: ReviewItem[] = result.flatMap((srsEntry) => {
{ // L @ breaking type safety, but this is mutually recursive too
associatedId: srsEntry.id, const srsQuestionGroup: SrsQuestionGroup = {
srsEntry,
questions: {},
} as SrsQuestionGroup;
const meaningQuestion: ReviewItem = {
parent: srsQuestionGroup,
type: ReviewItemType.MEANING, type: ReviewItemType.MEANING,
challenge: srsEntry.associated_kanji, challenge: srsEntry.associated_kanji,
possibleAnswers: srsEntry.meanings, possibleAnswers: srsEntry.meanings,
}, isCorrect: null,
{ timesRepeated: 0,
associatedId: srsEntry.id, };
const readingQuestion: ReviewItem = {
parent: srsQuestionGroup,
type: ReviewItemType.READING, type: ReviewItemType.READING,
challenge: srsEntry.associated_kanji, challenge: srsEntry.associated_kanji,
possibleAnswers: srsEntry.readings, possibleAnswers: srsEntry.readings,
}, isCorrect: null,
]); timesRepeated: 0,
};
srsQuestionGroup.questions.meaningQuestion = meaningQuestion;
srsQuestionGroup.questions.readingQuestion = readingQuestion;
return [meaningQuestion, readingQuestion];
});
const newReviewsShuffled = _.shuffle(newReviews); const newReviewsShuffled = _.shuffle(newReviews);
setReviewQueue(newReviewsShuffled); setReviewQueue(newReviewsShuffled);
@ -75,7 +102,7 @@ export function Component() {
if (!reviewQueue) return <Spinner />; if (!reviewQueue) return <Spinner />;
if (reviewQueue.length == 0) return <Done />; if (reviewQueue.length == 0) return <Done />;
const nextItem = reviewQueue[0]; const [nextItem, ...restOfQueue] = reviewQueue;
const possibleAnswers = new Set(nextItem.possibleAnswers); const possibleAnswers = new Set(nextItem.possibleAnswers);
const formSubmit = async (evt: FormEvent) => { const formSubmit = async (evt: FormEvent) => {
@ -83,28 +110,50 @@ export function Component() {
if (!reviewQueue) return; if (!reviewQueue) return;
const isCorrect = possibleAnswers.has(currentAnswer); const isCorrect = possibleAnswers.has(currentAnswer);
nextItem.isCorrect =
new Map([
[null, isCorrect],
[false, false],
[true, isCorrect],
]).get(nextItem.isCorrect) ?? isCorrect;
// Update the backend // Figure out if we need to update the backend
const params = { itemId: nextItem.associatedId, correct: isCorrect }; if (allQuestionsAnswered(nextItem.parent)) {
const result = await invoke("update_srs_item", params); console.log("SHIET");
console.log("result", result);
// 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) { if (!isCorrect) {
setIsIncorrect(true); setCurrentAnswer("");
setIncorrectTimes(incorrectTimes + 1);
// push it to the back of the queue
const lastItem = reviewQueue[reviewQueue.length - 1];
if (!_.isEqual(lastItem, nextItem)) setReviewQueue([...reviewQueue, nextItem]);
return; return;
} }
// Set up for next question! // Set up for next question!
setAnyProgress(true); setAnyProgress(true);
setIsIncorrect(false); setIncorrectTimes(0);
setCurrentAnswer(""); 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) => { const inputBox = (kanaInput: boolean) => {
@ -115,8 +164,12 @@ export function Component() {
setCurrentAnswer(newValue); setCurrentAnswer(newValue);
}; };
const className = classNames(styles["input-box"], isIncorrect && styles["incorrect"]); const className = classNames(styles["input-box"], incorrectTimes > 0 && styles["incorrect"]);
const placeholder = isIncorrect ? "Wrong! Try again..." : "Enter your answer..."; const placeholder =
{
0: "Enter your answer...",
1: "Wrong! Try again...",
}[incorrectTimes] || `Answer is: ${nextItem.possibleAnswers.join(", ")}`;
return ( return (
<InputGroup> <InputGroup>
@ -140,13 +193,16 @@ export function Component() {
return ( return (
<> <>
<p>{JSON.stringify(completedQueue.map((x) => x.challenge))}</p>
<p>{JSON.stringify(reviewQueue.map((x) => x.challenge))}</p>
{startingSize && ( {startingSize && (
<Progress <Progress
colorScheme="linkedin" colorScheme="linkedin"
hasStripe hasStripe
isAnimated isAnimated
max={startingSize} max={startingSize}
value={startingSize - reviewQueue.length} value={completedQueue.length}
/> />
)} )}
@ -182,11 +238,6 @@ export function Component() {
Back Back
</Button> </Button>
<details>
<summary>Debug</summary>
<pre>{JSON.stringify(nextItem, null, 2)}</pre>
</details>
<div className={styles.container}>{renderInside()}</div> <div className={styles.container}>{renderInside()}</div>
</Container> </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[];
}