diff --git a/.gitignore b/.gitignore index 8b4cb28..fc10af4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ yarn-error.log* notes/ data/ db/ +.aider* diff --git a/koala/fetch-lesson.ts b/koala/fetch-lesson.ts index aede48a..5f1fddf 100644 --- a/koala/fetch-lesson.ts +++ b/koala/fetch-lesson.ts @@ -4,7 +4,7 @@ import { map, shuffle } from "radash"; import { getUserSettings } from "./auth-helpers"; import { errorReport } from "./error-report"; import { maybeGetCardImageUrl } from "./image"; -import { calculateSchedulingData } from "./routes/import-cards"; +import { calculateSchedulingData } from "./trpc-routes/import-cards"; import { LessonType } from "./shared-types"; import { generateLessonAudio } from "./speech"; @@ -193,11 +193,6 @@ export default async function getLessons(p: GetLessonInputParams) { quizType: q.repetitions ? q.quizType : "dictation", }; - const audio = await generateLessonAudio({ - card: quiz.Card, - lessonType: quiz.quizType as LessonType, - speed: 100, - }); return { quizId: quiz.id, cardId: quiz.cardId, @@ -206,7 +201,15 @@ export default async function getLessons(p: GetLessonInputParams) { repetitions: quiz.repetitions, lapses: quiz.lapses, lessonType: quiz.quizType as LessonType, - audio, + definitionAudio: await generateLessonAudio({ + card: quiz.Card, + lessonType: "listening", + speed: 100, + }), + termAudio: await generateLessonAudio({ + card: quiz.Card, + lessonType: "speaking", + }), langCode: quiz.Card.langCode, lastReview: quiz.lastReview || 0, imageURL: await maybeGetCardImageUrl(quiz.Card.imageBlobId), diff --git a/koala/image.ts b/koala/image.ts index 01add69..854c0ad 100644 --- a/koala/image.ts +++ b/koala/image.ts @@ -68,10 +68,9 @@ export async function maybeAddImages(userId: string, take: number) { }); if (!cards.length) { - // console.log(`=== No cards left to illustrate ===`); return; } - // console.log(`=== Adding images to ${cards.length} cards ===`); + const x = await Promise.all(cards.map(maybeAddImageToCard)); console.log(cards.map((x) => x.term).join("\n")); console.log(x.join("\n")); diff --git a/koala/quiz-failure.tsx b/koala/quiz-failure.tsx deleted file mode 100644 index dfc28ea..0000000 --- a/koala/quiz-failure.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { HOTKEYS } from "@/pages/study"; -import { Button, Grid, Text, Container, Table } from "@mantine/core"; -import { useHotkeys } from "@mantine/hooks"; -import Link from "next/link"; -import { playAudio } from "./play-audio"; -import { YOU_HIT_FAIL } from "./study_reducer"; - -export function linkToEditPage(id: number) { - return Edit Card; -} - -function FailureTable(props: { - cardId: number; - definition: string; - lessonType: string; - rejectionText: string; - term: string; - userTranscription: string; -}) { - type RowProps = { - title: string; - value: string; - key: string; - }; - const IS_LISTENING = props.lessonType === "listening"; - const start: RowProps = { - title: "Task", - value: IS_LISTENING ? "Translate to English" : "Say in Target Language", - key: "lessonType", - }; - /** - * You answered a previous question incorrectly. - Quiz type speaking - Why it's wrong The input sentence is incomplete and does not provide enough context to determine if it is grammatically correct in Korean. - (A) What you said 몰라요. - (C) Definition How much is this? - (B) Term 이것은 얼마입니까? - */ - let rows: RowProps[]; - if (IS_LISTENING) { - rows = [ - start, - { - title: "Prompt", - value: props.term, - key: "1", - }, - { - title: "Response", - value: props.userTranscription, - key: "2", - }, - { - title: "Expected", - value: props.definition, - key: "3", - }, - ]; - } else { - rows = [ - start, - { - title: "Prompt", - value: props.term, // KO - key: "4", - }, - { - title: "Response", - value: props.userTranscription, // KO - key: "5", - }, - { - title: "Expected", - value: props.definition, // EN - key: "6", - }, - { - title: "Yours means", - value: props.rejectionText, // EN - key: "7", - }, - ]; - } - return ( - - - {rows.map((row: RowProps) => ( - - - - - ))} - - - - -
- {row.title} - {row.value}
{linkToEditPage(props.cardId)}
- ); -} -export function QuizFailure(props: { - id: number; - cardId: number; - term: string; - definition: string; - lessonType: string; - userTranscription: string; - rejectionText: string; - playbackAudio: string; - onFlag: () => void; - onDiscard?: () => void; - onClose: () => void; -}) { - const youHitFail = props.rejectionText == YOU_HIT_FAIL; - const doClose = async () => { - await playAudio(props.playbackAudio); - await playAudio(props.playbackAudio); - props.onClose(); - }; - useHotkeys([ - [HOTKEYS.AGREE, doClose], - [HOTKEYS.FLAG, props.onFlag], - [HOTKEYS.DISAGREE, () => !youHitFail && props.onDiscard?.()], - ]); - return ( - - - -
-

Incorrect

-
- You answered a previous question incorrectly. - - - - - - - - - - - - -
-
-
- ); -} diff --git a/koala/review/grade-buttons.tsx b/koala/review/grade-buttons.tsx new file mode 100644 index 0000000..ed068dd --- /dev/null +++ b/koala/review/grade-buttons.tsx @@ -0,0 +1,37 @@ +import { Button, Group } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; +import { Grade } from "femto-fsrs"; +import React from "react"; + +// Define the props for the component +interface DifficultyButtonsProps { + current: Grade | undefined; + onSelectDifficulty: (difficulty: Grade) => void; +} + +export const DifficultyButtons: React.FC = ({ + current, + onSelectDifficulty, +}) => { + const grades: (keyof typeof Grade)[] = ["AGAIN", "HARD", "GOOD", "EASY"]; + useHotkeys([ + ["a", () => onSelectDifficulty(Grade.AGAIN)], + ["s", () => onSelectDifficulty(Grade.HARD)], + ["d", () => onSelectDifficulty(Grade.GOOD)], + ["f", () => onSelectDifficulty(Grade.EASY)], + ]); + + return ( + + {grades.map((grade) => ( + + ))} + + ); +}; diff --git a/koala/review/listening-quiz.tsx b/koala/review/listening-quiz.tsx new file mode 100644 index 0000000..df1442f --- /dev/null +++ b/koala/review/listening-quiz.tsx @@ -0,0 +1,125 @@ +import { playAudio } from "@/koala/play-audio"; +import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; +import { trpc } from "@/koala/trpc-config"; +import { useVoiceRecorder } from "@/koala/use-recorder"; +import { Button, Stack, Text } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; +import { Grade } from "femto-fsrs"; +import { useEffect, useState, useCallback } from "react"; +import { DifficultyButtons } from "./grade-buttons"; +import { QuizComp } from "./types"; + +const REPETITIONS = 1; + +export const ListeningQuiz: QuizComp = ({ + quiz: card, + onGraded, + onComplete, +}) => { + // State variables + const [successfulAttempts, setSuccessfulAttempts] = useState(0); + const [isRecording, setIsRecording] = useState(false); + const [phase, setPhase] = useState<"play" | "record" | "done">("play"); + const transcribeAudio = trpc.transcribeAudio.useMutation(); + const voiceRecorder = useVoiceRecorder(handleRecordingResult); + + const transitionToNextPhase = useCallback( + () => setPhase(phase === "play" ? "record" : "done"), + [phase], + ); + + const handlePlayClick = async () => { + await playAudio(card.definitionAudio); + setPhase("record"); + }; + + const handleRecordClick = () => { + setIsRecording((prev) => !prev); + isRecording ? voiceRecorder.stop() : voiceRecorder.start(); + }; + + async function handleRecordingResult(audioBlob: Blob) { + setIsRecording(false); + try { + const base64Audio = await blobToBase64(await convertBlobToWav(audioBlob)); + const { result: transcription } = await transcribeAudio.mutateAsync({ + audio: base64Audio, + lang: "ko", + targetText: card.term, + }); + + if (transcription.trim() === card.term.trim()) + setSuccessfulAttempts((prev) => prev + 1); + transitionToNextPhase(); + } catch (error) { + setPhase("play"); // Retry + } + } + + const handleFailClick = () => { + onGraded(Grade.AGAIN); + onComplete("fail", "You hit the FAIL button"); + }; + + const handleDifficultySelect = (grade: Grade) => { + onGraded(grade); + onComplete("pass", ""); + }; + + useHotkeys([ + [ + "space", + () => (phase === "play" ? handlePlayClick() : handleRecordClick()), + ], + ]); + + useEffect(() => { + setSuccessfulAttempts(0); + setIsRecording(false); + setPhase("play"); + }, [card.term]); + + switch (phase) { + case "play": + return ( + + {card.term} + + + Repetitions: {successfulAttempts}/{REPETITIONS} + + + + ); + case "record": + return ( + + {card.term} + + {isRecording && Recording...} + + Repetitions: {successfulAttempts}/{REPETITIONS} + + + + ); + case "done": + return ( + + Select difficulty: + + + ); + default: + return
{`Unknown phase: ${phase}`}
; + } +}; diff --git a/koala/review/review-over.tsx b/koala/review/review-over.tsx new file mode 100644 index 0000000..c9ef86f --- /dev/null +++ b/koala/review/review-over.tsx @@ -0,0 +1,117 @@ +import { QuizState } from "./types"; +import { Button, Card, Center, Stack, Text, Title, Alert } from "@mantine/core"; +import { useState } from "react"; +import { DifficultyButtons } from "./grade-buttons"; +import { Grade } from "femto-fsrs"; + +type ReviewOverProps = { + state: QuizState[]; + onSave: () => Promise; + onUpdateDifficulty: (quizId: number, grade: Grade) => void; +}; + +export const ReviewOver = ({ + state, + onSave, + onUpdateDifficulty, +}: ReviewOverProps) => { + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + // Call the onFinalize prop to commit results to the server + try { + await onSave(); + } finally { + setIsSaving(false); + } + }; + + const getColor = (quizState: QuizState): string => { + switch (quizState.serverGradingResult) { + case "pass": + return "white"; + case "fail": + return "red"; + case "error": + return "yellow"; + default: + return "gray"; + } + }; + + const dontShowCorrect = (quizState: QuizState) => { + return quizState.serverGradingResult !== "pass"; + }; + + if (state.length === 0) { + return ( +
+ + No quizzes to review + +
+ ); + } + + return ( +
+ + + Review Summary + + Closing the browser tab early will cause changes to be lost. Please + finalize your review. + + + + + + {state.filter(dontShowCorrect).map((quizState) => ( + + + {quizState.quiz.term} + + onUpdateDifficulty(quizState.quiz.quizId, grade) + } + /> + Type: {quizState.quiz.lessonType} + + Your Response:{" "} + {quizState.response || "No response provided."} + + Feedback: {quizState.serverResponse || ""} + Definition: {quizState.quiz.definition} + {quizState.quiz.imageURL && ( + Card Illustration + )} + + + ))} + + + +
+ ); +}; diff --git a/koala/review/review-page.tsx b/koala/review/review-page.tsx new file mode 100644 index 0000000..d768f56 --- /dev/null +++ b/koala/review/review-page.tsx @@ -0,0 +1,121 @@ +import { quizReducer } from "@/koala/review/review-reducer"; +import { Grade } from "femto-fsrs"; +import { useEffect, useReducer, useState } from "react"; +import { Card, Title, Text, Stack, Image, Center } from "@mantine/core"; +import { DifficultyButtons } from "./grade-buttons"; +import { Props, Quiz, QuizComp, QuizProps } from "./types"; +import { ReviewOver } from "./review-over"; +import { SpeakingQuiz } from "./speaking-quiz"; +import { ListeningQuiz } from "./listening-quiz"; +import { trpc } from "../trpc-config"; + +const UnknownQuiz: QuizComp = (props) => { + const [currentGrade, setGrade] = useState(); + const handleGrade = (grade: Grade) => { + setGrade(grade); + props.onGraded(grade); + setGrade(undefined); + props.onComplete("pass", ""); + }; + return ( + + + Unknown Quiz ({props.quiz.lessonType}) + {props.quiz.definition} + {props.quiz.term} + + + + ); +}; + +// Lookup table for quiz components +const quizComponents: Record = { + dictation: ListeningQuiz, + listening: SpeakingQuiz, + speaking: SpeakingQuiz, +}; + +export const ReviewPage = (props: Props) => { + const [state, dispatch] = useReducer(quizReducer, { + quizzes: [], + currentQuizIndex: 0, + sessionStatus: "inProgress", + }); + const gradeQuiz = trpc.gradeQuiz.useMutation(); + useEffect(() => { + dispatch({ type: "LOAD_QUIZZES", quizzes: props.quizzes }); + }, [props.quizzes]); + + const currentQuizState = state.quizzes[state.currentQuizIndex]; + + if (currentQuizState) { + const quiz = currentQuizState.quiz; + const LessonComponent = quizComponents[quiz.lessonType] || UnknownQuiz; + const quizProps: QuizProps = { + quiz: currentQuizState.quiz, + onGraded(grade) { + dispatch({ type: "SET_GRADE", grade, quizId: quiz.quizId }); + dispatch({ type: "NEXT_QUIZ" }); + }, + onComplete(status, feedback) { + dispatch({ + type: "RECEIVE_GRADING_RESULT", + quizId: quiz.quizId, + result: status, + serverResponse: feedback || "", + }); + }, + }; + + // Updated illustration rendering + const illustration = ( + + {quiz.imageURL && ( + + )} + + ); + + return ( + // Center the content both vertically and horizontally +
+ + {illustration} + + +
+ ); + } else { + const reviewOverProps = { + state: state.quizzes, + async onSave() { + const grades = state.quizzes.map((q) => { + if (!q.grade) { + alert("Not all quizzes have been graded"); + throw new Error("Not all quizzes have been graded"); + } + return { + quizID: q.quiz.quizId, + perceivedDifficulty: q.grade, + }; + }); + await Promise.all(grades.map((grade) => gradeQuiz.mutateAsync(grade))); + await props.onSave(); // Fetch more potentially. + }, + onUpdateDifficulty(quizId: number, grade: Grade) { + dispatch({ type: "SET_GRADE", grade, quizId }); + }, + }; + return ; + } +}; diff --git a/koala/review/review-reducer.ts b/koala/review/review-reducer.ts new file mode 100644 index 0000000..cc6fc7b --- /dev/null +++ b/koala/review/review-reducer.ts @@ -0,0 +1,160 @@ +import { Grade } from "femto-fsrs"; +import { Action, ReviewState } from "./types"; + +export function quizReducer(state: ReviewState, action: Action): ReviewState { + switch (action.type) { + case "LOAD_QUIZZES": + return { + ...state, + quizzes: action.quizzes.map((quiz) => ({ + quiz, + status: "pending", + flagged: false, + notes: [], + })), + currentQuizIndex: 0, + sessionStatus: "inProgress", + }; + + case "SUBMIT_RESPONSE": + return { + ...state, + quizzes: state.quizzes.map((q, index) => + index === state.currentQuizIndex + ? { + ...q, + response: action.response, + } + : q, + ), + }; + + case "SET_GRADE": + return { + ...state, + quizzes: state.quizzes.map((q) => + q.quiz.quizId === action.quizId + ? { + ...q, + grade: action.grade, + } + : q, + ), + }; + + case "GIVE_UP": + return { + ...state, + quizzes: state.quizzes.map((q, index) => + index === state.currentQuizIndex + ? { + ...q, + difficulty: "AGAIN", + status: "completed", + } + : q, + ), + }; + + case "FLAG_CARD": + return { + ...state, + quizzes: state.quizzes.map((q, index) => + index === state.currentQuizIndex + ? { + ...q, + flagged: true, + } + : q, + ), + }; + + case "ADD_NOTE": + return { + ...state, + quizzes: state.quizzes.map((q, index) => + index === state.currentQuizIndex + ? { + ...q, + notes: [...q.notes, action.note], + } + : q, + ), + }; + + case "EDIT_CARD": + return { + ...state, + quizzes: state.quizzes.map((q) => + q.quiz.cardId === action.cardId + ? { + ...q, + quiz: { + ...q.quiz, + ...action.updates, + }, + } + : q, + ), + }; + + case "EXIT_EARLY": + return { + ...state, + sessionStatus: "exitedEarly", + }; + + case "RECEIVE_GRADING_RESULT": + return { + ...state, + quizzes: state.quizzes.map((q) => + q.quiz.quizId === action.quizId + ? { + ...q, + serverGradingResult: action.result, + serverResponse: action.serverResponse, + status: "graded", + grade: action.result === "fail" ? Grade.AGAIN : q.grade, + } + : q, + ), + }; + + case "UPDATE_DIFFICULTY": + return { + ...state, + quizzes: state.quizzes.map((q) => + q.quiz.quizId === action.quizId + ? { + ...q, + grade: action.grade, + } + : q, + ), + }; + + case "FINALIZE_REVIEW": + return { + ...state, + sessionStatus: "finalized", + }; + + case "NEXT_QUIZ": + const nextIndex = state.currentQuizIndex + 1; + return { + ...state, + currentQuizIndex: + nextIndex < state.quizzes.length ? nextIndex : state.quizzes.length, + }; + + case "PREVIOUS_QUIZ": + const prevIndex = state.currentQuizIndex - 1; + return { + ...state, + currentQuizIndex: prevIndex >= 0 ? prevIndex : state.currentQuizIndex, + }; + + default: + return state; + } +} diff --git a/koala/review/speaking-quiz.tsx b/koala/review/speaking-quiz.tsx new file mode 100644 index 0000000..380ca76 --- /dev/null +++ b/koala/review/speaking-quiz.tsx @@ -0,0 +1,118 @@ +import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; +import { trpc } from "@/koala/trpc-config"; +import { useVoiceRecorder } from "@/koala/use-recorder"; +import { Button, Stack, Text } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; +import { Grade } from "femto-fsrs"; +import { useEffect, useState } from "react"; +import { DifficultyButtons } from "./grade-buttons"; +import { QuizComp } from "./types"; + +export const SpeakingQuiz: QuizComp = (props) => { + const { quiz: card } = props; + const [isRecording, setIsRecording] = useState(false); + const [phase, setPhase] = useState<"prompt" | "recording" | "done">("prompt"); + const transcribeAudio = trpc.transcribeAudio.useMutation(); + const gradeSpeakingQuiz = trpc.gradeSpeakingQuiz.useMutation(); + const voiceRecorder = useVoiceRecorder(handleRecordingResult); + + const handleRecordClick = () => { + if (isRecording) { + voiceRecorder.stop(); + setIsRecording(false); + setPhase("done"); + } else { + setIsRecording(true); + setPhase("recording"); + voiceRecorder.start(); + } + }; + + async function handleRecordingResult(audioBlob: Blob) { + setIsRecording(false); + try { + const wavBlob = await convertBlobToWav(audioBlob); + const base64Audio = await blobToBase64(wavBlob); + + transcribeAudio + .mutateAsync({ + audio: base64Audio, + targetText: card.term, + lang: card.langCode as "ko", // e.g., "ko" for Korean + }) + .then(({ result: userTranscription }) => { + gradeSpeakingQuiz + .mutateAsync({ + userInput: userTranscription, + cardId: card.cardId, + }) + .then(({ isCorrect, feedback }) => { + const status = isCorrect ? "pass" : "fail"; + const f = status == "pass" ? "" : feedback; + props.onComplete(status, f); + }); + }); + } finally { + // Proceed to the 'done' phase to allow difficulty selection + setPhase("done"); + } + } + + // Handle Fail button click + const handleFailClick = () => { + props.onGraded(Grade.AGAIN); + }; + + // Handle Difficulty selection + const handleDifficultySelect = (grade: Grade) => { + props.onGraded(grade); + }; + + // Handle space key press + useHotkeys([ + [ + "space", + () => { + if (phase === "prompt" || phase === "recording") { + handleRecordClick(); + } + }, + ], + ]); + + // Reset state when card changes + useEffect(() => { + setIsRecording(false); + setPhase("prompt"); + }, [card.term]); + + return ( + + + {phase == "prompt" ? "Say in target language:" : "Select difficulty"} + + {card.definition} + + {(phase === "prompt" || phase === "recording") && ( + + )} + + {isRecording && Recording...} + + {(phase === "prompt" || phase === "recording") && ( + + )} + + {phase === "done" && ( + + )} + + ); +}; diff --git a/koala/review/types.ts b/koala/review/types.ts new file mode 100644 index 0000000..1dbd007 --- /dev/null +++ b/koala/review/types.ts @@ -0,0 +1,82 @@ +import { Grade } from "femto-fsrs"; + +// Define the types +export type Quiz = { + langCode: string; + term: string; + definition: string; + repetitions: number; + lapses: number; + lastReview: number; + quizId: number; + cardId: number; + lessonType: "listening" | "speaking" | "dictation"; + definitionAudio: string; + termAudio: string; + imageURL?: string | undefined; +}; + +export interface Props { + quizzes: Quiz[]; + onSave(): Promise; + // totalCards: number; + // quizzesDue: number; + // newCards: number; +} + +export interface QuizProps { + quiz: Quiz; + // Called when user sets grade. + onGraded: (grade: Grade) => void; + // Called when all async tasks / grading are done. + // Quiz will be stuck in "awaitingGrading" until this is called. + onComplete: (status: QuizStatus, feedback: string) => void; +} + +export type QuizComp = React.FC; + +interface Card { + cardId: number; +} + +// Define the status for each quiz +type QuizStatus = "pass" | "fail" | "error"; + +// Define the state for each quiz in the session +export interface QuizState { + quiz: Quiz; + response?: string; // User's response (text, audio, etc.) + grade?: Grade; + serverGradingResult?: QuizStatus; + serverResponse?: string; // Response from the server + flagged: boolean; + notes: string[]; +} + +// Define the overall state for the review session +export interface ReviewState { + quizzes: QuizState[]; + currentQuizIndex: number; + sessionStatus: "inProgress" | "finalized" | "exitedEarly"; +} + +// Define the possible actions +export type Action = + | { type: "LOAD_QUIZZES"; quizzes: Quiz[] } + | { type: "SUBMIT_RESPONSE"; response: string } + | { type: "SET_GRADE"; grade: Grade; quizId: number } + | { type: "GIVE_UP" } + | { type: "FLAG_CARD" } + | { type: "ADD_NOTE"; note: string } + | { type: "EDIT_CARD"; cardId: number; updates: Partial } + | { type: "EXIT_EARLY" } + | { + type: "RECEIVE_GRADING_RESULT"; + quizId: number; + result: QuizStatus; + serverResponse: string; + } + | { type: "FINALIZE_REVIEW" } + | { type: "NEXT_QUIZ" } + | { type: "PREVIOUS_QUIZ" } + | { type: "UPDATE_DIFFICULTY"; quizId: number; grade: Grade }; diff --git a/koala/review/v4-design.md b/koala/review/v4-design.md new file mode 100644 index 0000000..5402fa9 --- /dev/null +++ b/koala/review/v4-design.md @@ -0,0 +1,99 @@ +# Koala.Cards v4 Review UI + +Koala.Cards is a spaced repetition language learning application that quizzes users on a variety of language learning tasks (listening, speaking, response writing, comprehension). It is a web application written in React.JS, Typescript, Next.js, and tRPC. + +The application is centered around the following concepts: + +- **"cards"**: Key/value pairs of terms and definitions. These can be individual words, full sentences, reading passages, or other types of information pairs. +- **"decks"**: A collection of cards. +- **"quizzes"**: A specific review drill for a particular card, such as translating English to a target language. +- **"reviews"**: During a review session, the app quizzes users on a particular skill, such as their ability to translate a phrase into the target language or write a short comprehension answer. It uses a spaced repetition algorithm to schedule the next review time. + +Reviews for cards are scheduled using the FSRS spaced repetition algorithm. + +## The Review Session + +After logging in and creating a deck of cards, the user clicks on the tile representing a specific deck. This begins a "review session," which includes reviewing 7 cards at a time. + +The general flow for each individual review is as follows: + +1. The user sees a **prompt**, such as an audio play button or a sentence to read in the target language. +2. The user provides a **response**, such as text or audio input. +3. The user is asked to provide a **self-assessment**, where they pick a difficulty rating: AGAIN (or "WRONG" in the case of failed quizzes), HARD, GOOD, EASY. +4. The app progresses to the next review, or if all reviews are completed, it waits for the server to grade the quizzes ("grading"). + +## Prompts, Progression, and Grading + +The system is designed to support an arbitrary number of quiz types. + +Each quiz type has: + +- A **prompt**: A UI that presents a gradable task the user must complete. +- A **progression**: An action the user must take to progress to the next quiz, such as speaking into a microphone, typing a response, or selecting a multiple-choice question. +- A **grading system**: A server-side action that happens in the background to check the user's answer. This helps the user know if they failed. For example, if the user thought they provided the correct voice response, but it did not match the expected term, grading helps them correct their perceived difficulty level to "AGAIN." + +The UI dynamically renders a dispatched component based on the current quiz type. + +### Example Prompts, Progression, and Grading: + +- **Listening**: A sentence in the target language plays. The user can replay it by pressing the "play" button. The user progresses by typing an English translation of what they heard. The quiz is graded by an LLM trained to match equivalent phrases. +- **Dictation**: The user hears audio (or presses play to hear it again). They progress by recording an audio sample, repeating the phrase into the microphone. The quiz is graded via string matching after transcription using OpenAI Whisper. +- **Speaking**: The user is shown an English sentence and must translate it into the target language. They progress by speaking into the microphone in the target language. The server grades the quiz using audio transcription and LLM-assisted equivalence detection. + +Other quiz types that might be supported in the future include multiple choice, picture identification, short essay response, Chinese character drawing, etc. + +In all cases, the response is sent to the server (usually text, sometimes base 64 WAV audio) for grading. After performing the quiz, the user must select a perceived difficulty level (AGAIN, HARD, GOOD, EASY). The grade is not finalized until all 7 cards have been reviewed. + +## New Card Prompts + +Each card has a past review count. If the number is zero, it means the user has never seen the card before. When the card is completely new, a special "new review" type is performed. This review is generally easier than a normal review and involves the user interacting with a display page that shows information about the card (term, definition, audio, illustrations, etc.). + +Each quiz type dynamically decides how the new card prompt is rendered. For example, sometimes it is as simple as reading a phrase or listening to audio. + +## Giving Up + +If the user does not know how to respond, they can give up by clicking "Fail." The difficulty will be set to "AGAIN," and they will immediately be asked to do a corrective task, usually a dictation drill. + +## Finalizing the Review + +Once all 7 cards have been reviewed, the user is presented with a table of foldable rows with color-coded results. The tiles will have the following color codes: + +- **No color**: The user passed without issue. +- **Red**: The server graded the user's response as incorrect. +- **Yellow**: A server-side error occurred. +- **Gray**: Still awaiting a response from the server. + +The user can unfold each tile to reveal the following information: + +1. The user-selected difficulty level. This is set to "AGAIN" if server grading fails. The user is free to change this grade if they disagree. +2. User-provided artifacts, such as transcriptions received from the server or input provided. +3. Card information, such as the term/definition or illustrations. + +In the background, the app downloads the next 7 cards. If the server returns zero cards due for review, an "End Session" button is displayed. + +If the server returns more cards, a "Continue studying" button appears. + +Once the user clicks either button, the results are committed to the server-side database, and the scheduling data for the cards is updated accordingly. Closing the browser tab early will cause changes to be lost, so the user should be warned. + +The process continues until there are no more reviews left. + +## Pausing Reviews + +Sometimes a user may want to stop learning a card. Every step of the review wizard exposes a "pause reviews" button (known internally as "flagging" a card). This calls a tRPC method on the backend that sets the card's "flagged" value to true, meaning it will never be scheduled for review again once flagged. + +## Adding Notes + +Every step of the review wizard includes an "add note" feature, which allows the user to attach comments to a specific card. This is handled via the "addCardComment" feature. Examples of comments: + +- Why they got a card wrong. +- TODO items for later, such as adding more related cards. + +The comments can be viewed later in a different part of the application. + +## Exiting Early + +If the user wants to exit a session early, they can click "End Session," which will take them to the menu described in the "Finalizing the Review" section. + +## Editing Cards + +The user can edit the currently displayed card at any time. This is useful for fixing typos or making quick changes. diff --git a/koala/routes/get-mirroring-cards.ts b/koala/routes/get-mirroring-cards.ts deleted file mode 100644 index 1893522..0000000 --- a/koala/routes/get-mirroring-cards.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from "zod"; -import { prismaClient } from "../prisma-client"; -import { procedure } from "../trpc-procedure"; -import { generateLessonAudio } from "../speech"; -import { map, shuffle } from "radash"; - -export const getMirrorCards = procedure - .input(z.object({})) - .output( - z.array( - z.object({ - id: z.number(), - term: z.string(), - definition: z.string(), - audioUrl: z.string(), - translationAudioUrl: z.string(), - langCode: z.string(), - }), - ), - ) - .mutation(async ({ ctx }) => { - const cards = await prismaClient.card.findMany({ - where: { userId: ctx.user?.id || "000", flagged: false }, - orderBy: [{ mirrorRepetitionCount: "asc" }], - take: 200, - }); - // Order by length of 'term' field: - cards.sort((a, b) => b.term.length - a.term.length); - const shortList = shuffle(cards.slice(0, 100)).slice(0, 5); - return await map(shortList, async (card) => { - return { - id: card.id, - term: card.term, - definition: card.definition, - langCode: card.langCode, - translationAudioUrl: await generateLessonAudio({ - card, - lessonType: "speaking", - }), - audioUrl: await generateLessonAudio({ - card, - lessonType: "listening", - }), - }; - }); - }); diff --git a/koala/routes/get-playback-audio.ts b/koala/routes/get-playback-audio.ts deleted file mode 100644 index 38344b2..0000000 --- a/koala/routes/get-playback-audio.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { errorReport } from "@/koala/error-report"; -import { z } from "zod"; -import { getUserSettings } from "../auth-helpers"; -import { prismaClient } from "../prisma-client"; -import { procedure } from "../trpc-procedure"; -import { generateLessonAudio } from "../speech"; - -export const getPlaybackAudio = procedure - .input( - z.object({ - id: z.number(), - }), - ) - .output( - z.object({ - playbackAudio: z.string(), - }), - ) - .mutation(async ({ input, ctx }) => { - const userId = (await getUserSettings(ctx.user?.id)).user.id; - const quiz = await prismaClient.quiz.findUnique({ - where: { - id: input.id, - Card: { - userId, - }, - }, - include: { - Card: true, - }, - }); - if (!quiz) { - return errorReport("Quiz not found"); - } - const playbackAudio = await generateLessonAudio({ - card: quiz.Card, - lessonType: "dictation", - }); - return { playbackAudio }; - }); diff --git a/koala/routes/get-radio-item.ts b/koala/routes/get-radio-item.ts deleted file mode 100644 index 76c8950..0000000 --- a/koala/routes/get-radio-item.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; -import { procedure } from "../trpc-procedure"; -import { getUserSettings } from "../auth-helpers"; -import { prismaClient } from "../prisma-client"; -import { generateLessonAudio } from "../speech"; - -export const getRadioItem = procedure - .input( - z.object({ - skip: z.number(), - }), - ) - .output(z.object({ audio: z.string().optional() })) - .mutation(async ({ ctx, input }) => { - const userId = (await getUserSettings(ctx.user?.id)).user.id; - const quiz = await prismaClient.quiz.findFirst({ - where: { - Card: { - userId, - }, - }, - include: { - Card: true, - }, - orderBy: { - difficulty: "desc", - }, - skip: input.skip, - }); - if (!quiz) { - return { - audio: undefined, - }; - } - const audio = await generateLessonAudio({ - card: quiz.Card, - lessonType: "dictation", - }); - return { audio }; - }); diff --git a/koala/routes/grade-quiz.ts b/koala/routes/grade-quiz.ts deleted file mode 100644 index 5f6f056..0000000 --- a/koala/routes/grade-quiz.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { transcribeB64 } from "@/koala/transcribe"; -import { Card } from "@prisma/client"; -import { Grade } from "femto-fsrs"; -import { z } from "zod"; -import { prismaClient } from "../prisma-client"; -import { getQuizEvaluator } from "../quiz-evaluators"; -import { QuizEvaluatorOutput } from "../quiz-evaluators/types"; -import { procedure } from "../trpc-procedure"; -import { calculateSchedulingData, setGrade } from "./import-cards"; -import { LessonType } from "../shared-types"; -import { generateLessonAudio } from "../speech"; -import { isApprovedUser } from "../is-approved-user"; -import { maybeAddImages } from "../image"; -import { errorReport } from "../error-report"; - -type PerformExamOutput = z.infer; -type ResultContext = { - result: QuizEvaluatorOutput; - daysSinceReview: number; - perceivedDifficulty: Grade; - userInput: string; - card: Card; - quiz: { - id: number; - firstReview: number; - lastReview: number; - difficulty: number; - stability: number; - lapses: number; - repetitions: number; - quizType: LessonType; - }; -}; - -const ERROR = z.object({ - rejectionText: z.string(), - result: z.literal("error"), -}); - -const FAIL = z.object({ - grade: z.number(), - rejectionText: z.string(), - userTranscription: z.string(), - result: z.literal("fail"), - playbackAudio: z.string(), - rollbackData: z.object({ - difficulty: z.number(), - stability: z.number(), - nextReview: z.number(), - }), -}); - -const PASS = z.object({ - userTranscription: z.string(), - result: z.literal("pass"), -}); - -const performExamOutput = z.union([PASS, FAIL, ERROR]); -type FailResult = z.infer; -async function processFailure(ctx: ResultContext): Promise { - await setGrade(ctx.quiz, Grade.AGAIN); - const playbackAudio = await generateLessonAudio({ - card: ctx.card, - lessonType: "dictation", - speed: 90, - }); - return { - result: "fail", - grade: 0, - userTranscription: ctx.userInput, - rejectionText: ctx.result.userMessage, - rollbackData: calculateSchedulingData(ctx.quiz, ctx.perceivedDifficulty), - playbackAudio, - }; -} - -function processError(ctx: ResultContext): z.infer { - return { - rejectionText: ctx.result.userMessage, - result: "error", - }; -} - -async function processPass(ctx: ResultContext): Promise> { - const grade = ctx.perceivedDifficulty; - await setGrade(ctx.quiz, grade); - return { - userTranscription: "OK", - result: "pass", - }; -} - -export const gradeQuiz = procedure - .input( - z.object({ - perceivedDifficulty: z.number().min(1).max(4).int(), - audio: z.string().max(1000000), - id: z.number(), - }), - ) - .output(performExamOutput) - .mutation(async (x): Promise => { - const user = x.ctx.user; - if (!user) { - return { - rejectionText: "You are not logged in", - result: "error", - }; - } - - const quiz = await prismaClient.quiz.findUnique({ - where: { - id: x.input.id, - Card: { - userId: user.id, - }, - }, - include: { - Card: true, - }, - }); - - if (!quiz) { - return { - result: "error", - rejectionText: "No quiz found", - }; - } - const card = quiz?.Card; - // I don't like how implicit this is. Would be better to pass the quizType in. - const isNew = quiz.repetitions === 0 && quiz.lapses === 0; - const quizType = (isNew ? "dictation" : quiz.quizType) as LessonType; - let evaluator = getQuizEvaluator(quizType); - let prompt = ``; - let lang = "en" as const; - switch (quizType) { - case "dictation": - case "speaking": - prompt = card.term; - lang = card.langCode as "en"; - break; - case "listening": - prompt = card.definition; - break; - default: - return errorReport(`Unknown quiz type: ${quizType}`); - } - const transcript = await transcribeB64(x.input.audio, user.id, prompt, lang); - if (transcript.kind === "error") { - return { - result: "error", - rejectionText: "Transcription error", - }; - } - const result = await evaluator({ - quiz, - card, - userInput: transcript.text, - userID: user.id, - }); - const now = new Date().getTime(); - const lastReview = quiz.lastReview; - const resultContext: ResultContext = { - result, - daysSinceReview: Math.floor((now - lastReview) / (1000 * 60 * 60 * 24)), - perceivedDifficulty: x.input.perceivedDifficulty as Grade, - userInput: transcript.text, - card, - quiz: { - id: quiz.id, - firstReview: quiz.firstReview, - lastReview: quiz.lastReview, - difficulty: quiz.difficulty, - stability: quiz.stability, - lapses: quiz.lapses, - repetitions: quiz.repetitions, - quizType: quiz.quizType as LessonType, - }, - }; - - // Temporary experiment: Add 3 DALL-E images per review. - if (isApprovedUser(user.id)) { - maybeAddImages(user.id, 1); - } - switch (result.result) { - case "pass": - return processPass(resultContext); - case "fail": - return processFailure(resultContext); - case "error": - return processError(resultContext); - default: - return errorReport(`Unknown result: ${result.result}`); - } - }); diff --git a/koala/routes/manually-grade.ts b/koala/routes/manually-grade.ts deleted file mode 100644 index 7b930cb..0000000 --- a/koala/routes/manually-grade.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { errorReport } from "@/koala/error-report"; -import { z } from "zod"; -import { getUserSettings } from "../auth-helpers"; -import { prismaClient } from "../prisma-client"; -import { procedure } from "../trpc-procedure"; -import { setGrade } from "./import-cards"; - -export const manuallyGrade = procedure - .input( - z.object({ - id: z.number(), - grade: z.number(), - }), - ) - .mutation(async ({ input, ctx }) => { - const userId = (await getUserSettings(ctx.user?.id)).user.id; - const quiz = await prismaClient.quiz.findUnique({ - where: { - id: input.id, - Card: { - userId, - }, - }, - }); - if (!quiz) { - return errorReport("Quiz not found"); - } - await setGrade(quiz, input.grade); - }); diff --git a/koala/routes/rollback-grade.ts b/koala/routes/rollback-grade.ts deleted file mode 100644 index 4e9aa29..0000000 --- a/koala/routes/rollback-grade.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { errorReport } from "@/koala/error-report"; -import { z } from "zod"; -import { getUserSettings } from "../auth-helpers"; -import { prismaClient } from "../prisma-client"; -import { procedure } from "../trpc-procedure"; -import { timeUntil } from "@/koala/time-until"; - -export const rollbackGrade = procedure - .input( - z.object({ - id: z.number(), - schedulingData: z.object({ - difficulty: z.number(), - stability: z.number(), - nextReview: z.number(), - }), - }), - ) - .mutation(async ({ input, ctx }) => { - const userId = (await getUserSettings(ctx.user?.id)).user.id; - const quiz = await prismaClient.quiz.findUnique({ - where: { - id: input.id, - Card: { - userId, - }, - }, - }); - if (!quiz) { - return errorReport("Quiz not found"); - } - const data = { - where: { id: input.id }, - data: { - ...input.schedulingData, - lapses: Math.max(quiz.lapses - 1, 0), - }, - }; - await prismaClient.quiz.update(data); - console.log( - `Rollback grade. Next review: ${timeUntil(data.data.nextReview)}`, - ); - }); diff --git a/koala/routes/speak-text.ts b/koala/routes/speak-text.ts deleted file mode 100644 index 6b51a14..0000000 --- a/koala/routes/speak-text.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { draw } from "radash"; -import { z } from "zod"; -import { getUserSettings } from "../auth-helpers"; -import { procedure } from "../trpc-procedure"; -import { generateSpeechURL } from "../generate-speech-url"; -import { removeParens } from "../quiz-evaluators/evaluator-utils"; - -const LANG_CODES = z.union([ - z.literal("en"), - z.literal("es"), - z.literal("fr"), - z.literal("it"), - z.literal("ko"), -]); - -export const speakText = procedure - .input( - z.object({ - text: z.string().max(1000000), - lang: LANG_CODES, - }), - ) - .output( - z.object({ - url: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - await getUserSettings(ctx.user?.id); - const url = await generateSpeechURL({ - text: removeParens(input.text), - langCode: input.lang, - gender: draw(["F", "M", "N"] as const) || "N", - }); - return { url }; - }); diff --git a/koala/routes/translate-text.ts b/koala/routes/translate-text.ts deleted file mode 100644 index fe1e770..0000000 --- a/koala/routes/translate-text.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; -import { getUserSettings } from "../auth-helpers"; -import { translateToEnglish } from "../openai"; -import { procedure } from "../trpc-procedure"; -import { LANG_CODES } from "./bulk-create-cards"; - -export const translateText = procedure - .input( - z.object({ - text: z.string().max(1000000), - lang: LANG_CODES, - }), - ) - .output( - z.object({ - result: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - await getUserSettings(ctx.user?.id); - const result = await translateToEnglish(input.text, input.lang); - return { result }; - }); diff --git a/koala/study_reducer.ts b/koala/study_reducer.ts deleted file mode 100644 index 5a3f82a..0000000 --- a/koala/study_reducer.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { unique } from "radash"; -import { LessonType, QuizResult } from "./shared-types"; -import { errorReport } from "./error-report"; - -export type Quiz = { - lessonType: LessonType; - definition: string; - term: string; - audio: string; - cardId: number; - lapses: number; - quizId: number; - repetitions: number; - langCode: string; - lastReview: number; - imageURL?: string; -}; - -type Failure = { - id: number; - cardId: number; - definition: string; - lessonType: string; - rejectionText: string; - term: string; - userTranscription: string; - playbackAudio: string; - rollbackData?: { - difficulty: number; - stability: number; - nextReview: number; - }; -}; - -type CurrentItem = - | { type: "quiz"; value: Quiz } - | { type: "failure"; value: Failure } - | { type: "loading"; value: undefined } - | { type: "none"; value: undefined }; - -export type State = { - isRecording: boolean; - cardsById: Record; - failures: Failure[]; - idsAwaitingGrades: number[]; - idsWithErrors: number[]; - quizIDsForLesson: number[]; - newCards: number; - quizzesDue: number; - totalCards: number; - currentItem: CurrentItem; - failureReviewMode: boolean; - totalComplete: number; -}; - -export type Action = - | { type: "DID_GRADE"; id: number; result: QuizResult } - | { type: "FLAG_QUIZ"; cardId: number } - | { type: "ADD_FAILURE"; value: Failure } - | { type: "REMOVE_FAILURE"; id: number } - | { type: "BEGIN_RECORDING" } - | { type: "USER_GAVE_UP"; id: number; playbackAudio: string } - | { type: "END_RECORDING"; id: number } - | { - type: "ADD_MORE"; - quizzes: Quiz[]; - totalCards: number; - quizzesDue: number; - newCards: number; - }; - -export const YOU_HIT_FAIL = "Not provided."; - -// Creates a unique array of numbers but keeps the head -// in the 0th position to avoid changing the current quiz. -function betterUnique(input: number[]): number[] { - if (input.length < 2) { - return input; - } - const [head, ...tail] = input; - return [head, ...unique(tail.filter((x) => x !== head))]; -} - -const FAILURE_REVIEW_CUTOFF = 1; - -function maybeEnterFailureReview(state: State): State { - // Reasons to enter failure mode: - // 1. There are > FAILURE_REVIEW_CUTOFF failures to review. - // 2. There are no more quizzes to review. - - const lotsOfFailures = state.failures.length >= FAILURE_REVIEW_CUTOFF; - const anyFailures = state.failures.length > 0; - const noQuizzes = !state.cardsById[state.quizIDsForLesson[0]]; - if (lotsOfFailures || (anyFailures && noQuizzes)) { - return { - ...state, - failureReviewMode: true, - }; - } - - return state; -} - -function maybeExitFailureReview(state: State): State { - // Reasons to exit failure mode: - // 1. There are no more failures to review. - - if (state.failures.length === 0) { - return { - ...state, - failureReviewMode: false, - }; - } - return state; -} - -export function gotoNextQuiz(oldState: State): State { - if (oldState.isRecording) { - return errorReport("Cannot change quizzes while recording"); - } - - const state = maybeEnterFailureReview(oldState); - - const [nextFailure, ...restFailures] = state.failures; - if (nextFailure && state.failureReviewMode) { - return maybeExitFailureReview({ - ...state, - currentItem: { type: "failure", value: nextFailure }, - quizIDsForLesson: betterUnique(state.quizIDsForLesson), - failures: restFailures, - }); - } - const [nextQuizID, ...restQuizIDs] = state.quizIDsForLesson; - const nextQuiz = state.cardsById[nextQuizID]; - if (nextQuiz) { - return maybeExitFailureReview({ - ...state, - currentItem: { type: "quiz", value: nextQuiz }, - quizIDsForLesson: betterUnique([...restQuizIDs, nextQuizID]), - }); - } - return maybeExitFailureReview({ - ...state, - currentItem: { type: "none", value: undefined }, - }); -} - -export const newQuizState = (state: Partial = {}): State => { - const cardsById = state.cardsById || {}; - const remainingQuizIDs = Object.keys(cardsById).map((x) => parseInt(x)); - return { - /** Re-trying a quiz after an error is distracting. - * Instead of re-quizzing on errors, we just remove them - * from the lesson. */ - idsWithErrors: [], - idsAwaitingGrades: [], - failures: [], - cardsById, - quizIDsForLesson: remainingQuizIDs, - isRecording: false, - totalCards: 0, - quizzesDue: 0, - newCards: 0, - currentItem: { type: "loading", value: undefined }, - failureReviewMode: false, - totalComplete: 0, - ...state, - }; -}; - -function removeCard(oldState: State, id: number): State { - let quizIDsForLesson = oldState.quizIDsForLesson.filter((x) => x !== id); - let cardsById: State["cardsById"] = {}; - const old = oldState.cardsById; - // A mark-and-sweep garbage collector of sorts. - quizIDsForLesson.forEach((id) => { - cardsById[id] = old[id]; - }); - oldState.failures.forEach((failure) => { - cardsById[failure.id] = old[failure.id]; - }); - return { - ...oldState, - quizIDsForLesson, - cardsById, - }; -} - -function reduce(state: State, action: Action): State { - console.log(action); - switch (action.type) { - case "ADD_FAILURE": - return { - ...state, - failures: [...state.failures, action.value], - }; - case "REMOVE_FAILURE": - return gotoNextQuiz({ - ...state, - failures: state.failures.filter((x) => x.id !== action.id), - }); - case "BEGIN_RECORDING": - return { - ...state, - isRecording: true, - }; - case "USER_GAVE_UP": - const curr = state.currentItem; - if (curr.type === "quiz" || curr.type === "failure") { - const card = curr.value; - const state2 = gotoNextQuiz({ - ...state, - failures: [ - { - id: action.id, - cardId: card.cardId, - term: card.term, - definition: card.definition, - lessonType: card.lessonType, - userTranscription: YOU_HIT_FAIL, - rejectionText: YOU_HIT_FAIL, - playbackAudio: action.playbackAudio, - rollbackData: undefined, - }, - ...state.failures, - ], - }); - return removeCard(state2, action.id); - } - return errorReport( - "Expected a quiz, got " + curr.type || "nullish value", - ); - case "FLAG_QUIZ": - const filter = (quizID: number) => - state.cardsById[quizID]?.cardId !== action.cardId; - return gotoNextQuiz({ - ...state, - // Remove all quizzes with this cardID - quizIDsForLesson: state.quizIDsForLesson.filter(filter), - }); - case "END_RECORDING": - const arr = [...state.idsAwaitingGrades, action.id]; - const set = new Set(arr); - return gotoNextQuiz({ - ...state, - isRecording: false, - idsAwaitingGrades: arr, - failures: state.failures.filter((x) => !set.has(x.id)), - quizIDsForLesson: state.quizIDsForLesson.filter((x) => !set.has(x)), - }); - case "DID_GRADE": - const idsAwaitingGrades: number[] = []; - state.idsAwaitingGrades.forEach((id) => { - if (id !== action.id) { - idsAwaitingGrades.push(id); - } - }); - const isError = action.result === "error"; - const idsWithErrors: number[] = isError - ? [...state.idsWithErrors, action.id] - : state.idsWithErrors; - return { - ...removeCard(state, action.id), - idsAwaitingGrades, - idsWithErrors, - totalComplete: state.totalComplete + 1, - }; - case "ADD_MORE": - const newStuff = action.quizzes.map((x) => x.quizId); - const oldStuff = state.quizIDsForLesson; - const nextQuizIDsForLesson = betterUnique([...oldStuff, ...newStuff]); - const nextcardsById: Record = {}; - nextQuizIDsForLesson.forEach((id) => { - nextcardsById[id] ??= state.cardsById[id]; - }); - action.quizzes.forEach((card) => { - nextcardsById[card.quizId] ??= card; - }); - return { - ...state, - cardsById: nextcardsById, - quizIDsForLesson: nextQuizIDsForLesson, - totalCards: action.totalCards, - quizzesDue: action.quizzesDue, - newCards: action.newCards, - }; - default: - console.warn("Unhandled action", action); - return state; - } -} - -export function quizReducer(state: State, action: Action): State { - const nextState = reduce(state, action); - return nextState; -} diff --git a/koala/trpc-config.ts b/koala/trpc-config.ts index 19d75e5..65c0582 100644 --- a/koala/trpc-config.ts +++ b/koala/trpc-config.ts @@ -1,6 +1,6 @@ import { httpBatchLink } from "@trpc/client"; import { createTRPCNext } from "@trpc/next"; -import type { AppRouter } from "./routes/main"; +import type { AppRouter } from "./trpc-routes/main"; import superjson from "superjson"; function getBaseUrl() { diff --git a/koala/routes/bulk-create-cards.ts b/koala/trpc-routes/bulk-create-cards.ts similarity index 100% rename from koala/routes/bulk-create-cards.ts rename to koala/trpc-routes/bulk-create-cards.ts diff --git a/koala/routes/delete-card.ts b/koala/trpc-routes/delete-card.ts similarity index 100% rename from koala/routes/delete-card.ts rename to koala/trpc-routes/delete-card.ts diff --git a/koala/routes/delete-flagged-card.ts b/koala/trpc-routes/delete-flagged-card.ts similarity index 100% rename from koala/routes/delete-flagged-card.ts rename to koala/trpc-routes/delete-flagged-card.ts diff --git a/koala/routes/edit-card.ts b/koala/trpc-routes/edit-card.ts similarity index 100% rename from koala/routes/edit-card.ts rename to koala/trpc-routes/edit-card.ts diff --git a/koala/routes/edit-user-settings.ts b/koala/trpc-routes/edit-user-settings.ts similarity index 100% rename from koala/routes/edit-user-settings.ts rename to koala/trpc-routes/edit-user-settings.ts diff --git a/koala/routes/export-cards.ts b/koala/trpc-routes/export-cards.ts similarity index 100% rename from koala/routes/export-cards.ts rename to koala/trpc-routes/export-cards.ts diff --git a/koala/routes/faucet.ts b/koala/trpc-routes/faucet.ts similarity index 100% rename from koala/routes/faucet.ts rename to koala/trpc-routes/faucet.ts diff --git a/koala/routes/flag-card.ts b/koala/trpc-routes/flag-card.ts similarity index 100% rename from koala/routes/flag-card.ts rename to koala/trpc-routes/flag-card.ts diff --git a/koala/routes/get-all-cards.ts b/koala/trpc-routes/get-all-cards.ts similarity index 100% rename from koala/routes/get-all-cards.ts rename to koala/trpc-routes/get-all-cards.ts diff --git a/koala/routes/get-next-quizzes.ts b/koala/trpc-routes/get-next-quizzes.ts similarity index 97% rename from koala/routes/get-next-quizzes.ts rename to koala/trpc-routes/get-next-quizzes.ts index db52d59..3a6e177 100644 --- a/koala/routes/get-next-quizzes.ts +++ b/koala/trpc-routes/get-next-quizzes.ts @@ -16,7 +16,8 @@ export const Quiz = z.object({ z.literal("speaking"), z.literal("dictation"), ]), - audio: z.string(), + definitionAudio: z.string(), + termAudio: z.string(), langCode: z.string(), lastReview: z.number(), imageURL: z.string().optional(), diff --git a/koala/routes/get-one-card.ts b/koala/trpc-routes/get-one-card.ts similarity index 95% rename from koala/routes/get-one-card.ts rename to koala/trpc-routes/get-one-card.ts index 235c8be..933a365 100644 --- a/koala/routes/get-one-card.ts +++ b/koala/trpc-routes/get-one-card.ts @@ -38,7 +38,8 @@ export const getOneCard = procedure repetitions: quiz.repetitions, lapses: quiz.lapses, lessonType: quiz.quizType as LessonType, - audio: "", + definitionAudio: "", + termAudio: "", langCode: card.langCode, lastReview: quiz.lastReview, }; diff --git a/koala/routes/get-user-settings.ts b/koala/trpc-routes/get-user-settings.ts similarity index 100% rename from koala/routes/get-user-settings.ts rename to koala/trpc-routes/get-user-settings.ts diff --git a/koala/trpc-routes/grade-quiz.ts b/koala/trpc-routes/grade-quiz.ts new file mode 100644 index 0000000..23f80c6 --- /dev/null +++ b/koala/trpc-routes/grade-quiz.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { prismaClient } from "../prisma-client"; +import { procedure } from "../trpc-procedure"; +import { setGrade } from "./import-cards"; +import { isApprovedUser } from "../is-approved-user"; +import { maybeAddImages } from "../image"; +import { Grade } from "femto-fsrs"; + +export const gradeQuiz = procedure + .input( + z.object({ + perceivedDifficulty: z.number().min(1).max(4).int(), + quizID: z.number(), + }), + ) + .output(z.object({})) + .mutation(async (x): Promise<{}> => { + const user = x.ctx.user; + if (!user) { + return { + rejectionText: "You are not logged in", + result: "error", + }; + } + + const grade = x.input.perceivedDifficulty as Grade; + const quiz = await prismaClient.quiz.findUnique({ + where: { + id: x.input.quizID, + Card: { + userId: user.id, + }, + }, + include: { + Card: true, + }, + }); + + if (!quiz) { + return { + result: "error", + rejectionText: "No quiz found", + }; + } + + // Temporary experiment: Add 3 DALL-E images per review. + if (isApprovedUser(user.id)) { + maybeAddImages(user.id, 1); + } + await setGrade(quiz, grade); + return {}; + }); diff --git a/koala/trpc-routes/grade-speaking-quiz.ts b/koala/trpc-routes/grade-speaking-quiz.ts new file mode 100644 index 0000000..49a018b --- /dev/null +++ b/koala/trpc-routes/grade-speaking-quiz.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { getUserSettings } from "../auth-helpers"; +import { procedure } from "../trpc-procedure"; +import { speaking } from "../quiz-evaluators/speaking"; +import { prismaClient } from "../prisma-client"; + +export const gradeSpeakingQuiz = procedure + .input( + z.object({ + userInput: z.string(), + cardId: z.number(), + }), + ) + .output( + z.object({ + isCorrect: z.boolean(), + feedback: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userID = (await getUserSettings(ctx.user?.id)).user.id; + + const card = await prismaClient.card.findUnique({ + where: { id: input.cardId, userId: userID }, + }); + + if (!card) { + throw new Error("Card not found"); + } + + const quiz = await prismaClient.quiz.findFirst({ + where: { + cardId: card.id, + quizType: "speaking", + }, + }); + + if (!quiz) { + throw new Error("Quiz not found"); + } + + const result = await speaking({ + card, + quiz, + userID, + userInput: input.userInput, + }); + + return { + isCorrect: result.result === "pass", + feedback: result.userMessage, + }; + }); diff --git a/koala/routes/import-cards.ts b/koala/trpc-routes/import-cards.ts similarity index 80% rename from koala/routes/import-cards.ts rename to koala/trpc-routes/import-cards.ts index 25614ec..60fb7c8 100644 --- a/koala/routes/import-cards.ts +++ b/koala/trpc-routes/import-cards.ts @@ -88,15 +88,27 @@ export async function setGrade( grade: Grade, now = Date.now(), ) { + const data = { + ...quiz, + ...calculateSchedulingData(quiz, grade, now), + repetitions: (quiz.repetitions || 0) + 1, + lastReview: now, + firstReview: quiz.firstReview || now, + lapses: (quiz.lapses || 0) + (grade === Grade.AGAIN ? 1 : 0), + }; + const { id, nextReview } = await prismaClient.quiz.update({ where: { id: quiz.id }, data: { - ...quiz, - ...calculateSchedulingData(quiz, grade, now), - repetitions: (quiz.repetitions || 0) + 1, - lastReview: now, - firstReview: quiz.firstReview || now, - lapses: (quiz.lapses || 0) + (grade === Grade.AGAIN ? 1 : 0), + // Stats: + difficulty: data.difficulty, + lapses: data.lapses, + repetitions: data.repetitions, + stability: data.stability, + // Timestamps: + firstReview: data.firstReview, + lastReview: data.lastReview, + nextReview: data.nextReview, }, }); console.log(`Quiz ${id} next review: ${timeUntil(nextReview)}`); diff --git a/koala/routes/level-reviews.ts b/koala/trpc-routes/level-reviews.ts similarity index 100% rename from koala/routes/level-reviews.ts rename to koala/trpc-routes/level-reviews.ts diff --git a/koala/routes/main.ts b/koala/trpc-routes/main.ts similarity index 72% rename from koala/routes/main.ts rename to koala/trpc-routes/main.ts index abf5cee..a465086 100644 --- a/koala/routes/main.ts +++ b/koala/trpc-routes/main.ts @@ -8,20 +8,14 @@ import { exportCards } from "./export-cards"; import { faucet } from "./faucet"; import { flagCard } from "./flag-card"; import { getAllCards } from "./get-all-cards"; -import { getMirrorCards } from "./get-mirroring-cards"; import { getNextQuizzes } from "./get-next-quizzes"; import { getOneCard } from "./get-one-card"; -import { getPlaybackAudio } from "./get-playback-audio"; -import { getRadioItem } from "./get-radio-item"; import { getUserSettings } from "./get-user-settings"; import { gradeQuiz } from "./grade-quiz"; +import { gradeSpeakingQuiz } from "./grade-speaking-quiz"; import { levelReviews } from "./level-reviews"; -import { manuallyGrade } from "./manually-grade"; import { parseCards } from "./parse-cards"; -import { rollbackGrade } from "./rollback-grade"; -import { speakText } from "./speak-text"; import { transcribeAudio } from "./transcribe-audio"; -import { translateText } from "./translate-text"; import { viewTrainingData } from "./view-training-data"; export const appRouter = router({ @@ -36,19 +30,13 @@ export const appRouter = router({ getAllCards, getNextQuizzes, getOneCard, - getPlaybackAudio, - getRadioItem, getUserSettings, gradeQuiz, levelReviews, - manuallyGrade, parseCards, - rollbackGrade, - speakText, transcribeAudio, - translateText, viewTrainingData, - getMirrorCards, + gradeSpeakingQuiz, }); export type AppRouter = typeof appRouter; diff --git a/koala/routes/parse-cards.ts b/koala/trpc-routes/parse-cards.ts similarity index 100% rename from koala/routes/parse-cards.ts rename to koala/trpc-routes/parse-cards.ts diff --git a/koala/routes/transcribe-audio.ts b/koala/trpc-routes/transcribe-audio.ts similarity index 100% rename from koala/routes/transcribe-audio.ts rename to koala/trpc-routes/transcribe-audio.ts diff --git a/koala/routes/view-training-data.ts b/koala/trpc-routes/view-training-data.ts similarity index 97% rename from koala/routes/view-training-data.ts rename to koala/trpc-routes/view-training-data.ts index 52c5a32..67f1baf 100644 --- a/koala/routes/view-training-data.ts +++ b/koala/trpc-routes/view-training-data.ts @@ -38,7 +38,7 @@ export const viewTrainingData = procedure }, take: 500, }); - console.log(JSON.stringify(data)); + return data.map((trainingData) => { return { id: trainingData.id, diff --git a/pages/_nav.tsx b/pages/_nav.tsx index fda0b7f..5ab1284 100644 --- a/pages/_nav.tsx +++ b/pages/_nav.tsx @@ -27,7 +27,7 @@ const NavBar = () => { }; const links = [ - { path: "/study", name: "Study" }, + { path: "/review", name: "Review" }, { path: "/create", name: "Add" }, { path: "/cards", name: "Cards" }, { path: "/user", name: "Settings" }, diff --git a/pages/api/trpc/[trpc].ts b/pages/api/trpc/[trpc].ts index c503a7e..7b75aa3 100644 --- a/pages/api/trpc/[trpc].ts +++ b/pages/api/trpc/[trpc].ts @@ -1,5 +1,5 @@ import * as trpcNext from "@trpc/server/adapters/next"; -import { appRouter } from "../../../koala/routes/main"; +import { appRouter } from "../../../koala/trpc-routes/main"; import { getServerSession } from "next-auth"; import { authOptions } from "../auth/[...nextauth]"; import { prismaClient } from "@/koala/prisma-client"; diff --git a/pages/faucet.tsx b/pages/faucet.tsx index 0fc0cdc..0f3dca4 100644 --- a/pages/faucet.tsx +++ b/pages/faucet.tsx @@ -1,25 +1,45 @@ import React from "react"; -import { trpc } from "@/koala/trpc-config"; +import { ReviewOver } from "@/koala/review/review-over"; +import { Grade } from "femto-fsrs"; +import { QuizState } from "@/koala/review/types"; /** The faucet component has a button that when clicked * calls the "faucet" trpc mutation: */ export default function Faucet(_props: {}) { - const f = trpc.faucet.useMutation(); - const [result, setResult] = React.useState("Press Button"); - const onClick = () => { - const no = () => { - setResult("Nope"); - }; - setResult("Loading..."); - f.mutateAsync({}).then(({ message }) => { - setResult(message); - }, no); + const state: QuizState[] = [ + { + quiz: { + langCode: "ko", + term: "The term.", + definition: "The definition.", + repetitions: 0, + lapses: 0, + lastReview: 0, + quizId: 0, + cardId: 0, + lessonType: "speaking", + definitionAudio: "", + termAudio: "", + imageURL: undefined, + }, + response: "???", + grade: Grade.AGAIN, + serverGradingResult: "fail", + serverResponse: "??", // Response from the server (e.g., transcription) + flagged: true, + notes: [], + }, + ]; + const props = { + state, + async onSave() { + alert("TODO: Finalize review session"); + }, + onUpdateDifficulty(quizId: number, grade: Grade) { + alert(`TODO: Update quiz ${quizId} with grade ${grade}`); + }, + moreQuizzesAvailable: false, }; - return ( -
- - {result && {"Example"}} -
- ); + return ; } diff --git a/pages/mirror.tsx b/pages/mirror.tsx deleted file mode 100644 index 2aa813f..0000000 --- a/pages/mirror.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useVoiceRecorder } from "@/koala/use-recorder"; -import { playAudio } from "@/koala/play-audio"; -import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; -import { trpc } from "@/koala/trpc-config"; -import { useHotkeys } from "@mantine/hooks"; - -type Card = { - term: string; - definition: string; - translationAudioUrl: string; - audioUrl: string; -}; - -const RecordingControls = ({ - isRecording, - successfulAttempts, - failedAttempts, - isProcessingRecording, - handleClick, -}: { - isRecording: boolean; - successfulAttempts: number; - failedAttempts: number; - isProcessingRecording: boolean; - handleClick: () => void; -}) => { - let message: string; - if (isRecording) { - message = "Recording..."; - } else { - message = "Start recording"; - } - - return ( -
- -

{successfulAttempts} repetitions correct.

-

{isProcessingRecording ? 1 : 0} repetitions awaiting grade.

-

{failedAttempts} repetitions failed.

-

{3 - successfulAttempts} repetitions left.

-
- ); -}; - -// Component to handle quizzing for a single sentence -export const SentenceQuiz = ({ - card, - setErrorMessage, - onCardCompleted, -}: { - card: Card; - setErrorMessage: (error: string) => void; - onCardCompleted: () => void; -}) => { - // State variables - const [successfulAttempts, setSuccessfulAttempts] = useState(0); - const [failedAttempts, setFailedAttempts] = useState(0); - const [isRecording, setIsRecording] = useState(false); - const [isProcessingRecording, setIsProcessingRecording] = useState(false); - - // TRPC mutations - const transcribeAudio = trpc.transcribeAudio.useMutation(); - - // Voice recorder hook - const voiceRecorder = useVoiceRecorder(handleRecordingResult); - - // Handle button click - const handleClick = async () => { - if (successfulAttempts >= 3) { - // Do nothing if already completed - return; - } - - if (isRecording) { - // Stop recording - voiceRecorder.stop(); - setIsRecording(false); - } else { - playAudio(card.audioUrl); - // Start recording - setIsRecording(true); - voiceRecorder.start(); - } - }; - - // Use hotkeys to trigger handleClick on space bar press - useHotkeys([["space", handleClick]]); - - // Reset state variables when the term changes - useEffect(() => { - setSuccessfulAttempts(0); - setFailedAttempts(0); - setIsRecording(false); - setIsProcessingRecording(false); - }, [card.term]); - - // Handle the result after recording is finished - async function handleRecordingResult(audioBlob: Blob) { - setIsProcessingRecording(true); - try { - // Convert the recorded audio blob to WAV and then to base64 - const wavBlob = await convertBlobToWav(audioBlob); - const base64Audio = await blobToBase64(wavBlob); - - // Transcribe the audio - const { result: transcription } = await transcribeAudio.mutateAsync({ - audio: base64Audio, - lang: "ko", - targetText: card.term, - }); - - // Compare the transcription with the target sentence - if (transcription.trim() === card.term.trim()) { - setSuccessfulAttempts((prev) => prev + 1); - } else { - setFailedAttempts((prev) => prev + 1); - } - } catch (err) { - setErrorMessage("Error processing the recording."); - } finally { - setIsProcessingRecording(false); - } - } - - // Effect to handle successful completion - useEffect(() => { - if (successfulAttempts >= 3) { - // Play the translation audio - playAudio(card.translationAudioUrl).then(() => { - // After audio finishes, proceed to next sentence - onCardCompleted(); - }); - } - }, [successfulAttempts]); - - return ( -
- - {successfulAttempts === 0 &&

{card.term}

} -
- ); -}; - -export default function Mirror() { - // State variables - const [terms, setTerms] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [errorMessage, setErrorMessage] = useState(null); - - const getMirrorCards = trpc.getMirrorCards.useMutation({}); - // Fetch translations for all sentences - const fetchCards = async () => { - try { - const translatedTerms = await getMirrorCards.mutateAsync({}); - setTerms(translatedTerms); - setCurrentIndex(0); - } catch (err) { - setErrorMessage("Failed to fetch sentences."); - } - }; - - useEffect(() => { - fetchCards(); - }, []); - - const handleCardCompleted = () => { - setCurrentIndex((prevIndex) => prevIndex + 1); - }; - - // When the list is emptied, re-fetch more cards from the server - useEffect(() => { - if (currentIndex >= terms.length) { - // Re-fetch more cards from the server - fetchCards(); - } - }, [currentIndex, terms.length]); - - if (errorMessage) { - return
Error: {errorMessage}
; - } - - if (terms.length === 0) { - return
Loading sentences...
; - } - - const currentTerm = terms[currentIndex]; - - return ( - - ); -} diff --git a/pages/radio.tsx b/pages/radio.tsx deleted file mode 100644 index a29a408..0000000 --- a/pages/radio.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { playAudio } from "@/koala/play-audio"; -import { trpc } from "@/koala/trpc-config"; -import { Container, Title, Card, Button, Group } from "@mantine/core"; -import { useRef, useState } from "react"; - -export default function MediaPlayer() { - const doGetRadioItem = trpc.getRadioItem.useMutation(); - const skipRef = useRef(0); - const [skip, setSkip] = useState(0); - - const inc = () => { - skipRef.current = skipRef.current + 1; - setSkip(skipRef.current); - }; - - const reset = () => { - skipRef.current = 0; - setSkip(0); - }; - - async function doPlay() { - const { audio } = await doGetRadioItem.mutateAsync({ - skip: skipRef.current, - }); - if (audio) { - inc(); - await playAudio(audio); - await playAudio(audio); - setTimeout(doPlay, 2500); - } else { - reset(); - } - } - - return ( - - Media Player - - Played {skip} items. - - - - - - ); -} diff --git a/pages/review.tsx b/pages/review.tsx new file mode 100644 index 0000000..1bcd326 --- /dev/null +++ b/pages/review.tsx @@ -0,0 +1,35 @@ +import MicrophonePermissions from "@/koala/microphone-permissions"; +import { ReviewPage } from "@/koala/review/review-page"; +import { trpc } from "@/koala/trpc-config"; +import { useEffect, useState } from "react"; + +export default function Review() { + const mutation = trpc.getNextQuizzes.useMutation(); + const [quizzes, setQuizzes] = useState([] as any); + + const fetchQuizzes = () => { + mutation.mutate( + { notIn: [], take: 7 }, + { onSuccess: (data) => setQuizzes(data.quizzes) }, + ); + }; + + useEffect(() => { + fetchQuizzes(); + }, []); + + const onSave = async () => { + // Called by child component when it wants more quizzes. + fetchQuizzes(); + }; + + let el =
Loading...
; + + if (mutation.isError) { + el =
Error occurred: {mutation.error.message}
; + } else if (quizzes.length > 0) { + el = ; + } + + return MicrophonePermissions(el); +} diff --git a/pages/study.tsx b/pages/study.tsx deleted file mode 100644 index 0442674..0000000 --- a/pages/study.tsx +++ /dev/null @@ -1,525 +0,0 @@ -import { errorReport } from "@/koala/error-report"; -import MicrophonePermissions from "@/koala/microphone-permissions"; -import { playAudio } from "@/koala/play-audio"; -import { QuizFailure, linkToEditPage } from "@/koala/quiz-failure"; -import { blobToBase64, convertBlobToWav } from "@/koala/record-button"; -import { useUserSettings } from "@/koala/settings-provider"; -import { - Action, - Quiz, - State, - gotoNextQuiz, - newQuizState, - quizReducer, -} from "@/koala/study_reducer"; -import { timeUntil } from "@/koala/time-until"; -import { trpc } from "@/koala/trpc-config"; -import { useVoiceRecorder } from "@/koala/use-recorder"; -import { Button, Container, Grid } from "@mantine/core"; -import { useHotkeys } from "@mantine/hooks"; -import { Grade } from "femto-fsrs"; -import Link from "next/link"; -import { Dispatch, useEffect, useReducer, useState } from "react"; - -type MutationData = ReturnType; -type QuizData = NonNullable; -type QuizViewProps = { - quiz: Quiz; - awaitingGrades: number; - newCards: number; - queueSize: number; - quizzesDue: number; - totalCards: number; - pendingFailures: number; - isRecording: boolean; - playQuizAudio: () => Promise; - flagQuiz: () => Promise; - startRecording(grade: Grade): Promise; - stopRecording: () => Promise; - totalComplete: number; -}; - -type StudyHeaderProps = { - lessonType: keyof typeof HEADER; - langCode: string; - isRecording: boolean; -}; -type QuizAssertion = (q: Quiz | undefined) => asserts q is Quiz; -type HotkeyButtonProps = { - hotkey: string; - label: string; - onClick: () => void; - disabled?: boolean; -}; - -const HEADER_STYLES = { - display: "flex", - alignItems: "center", - justifyContent: "center", - marginBottom: "20px", -}; -const LANG_CODE_NAMES: Record = { - EN: "English", - IT: "Italian", - FR: "French", - ES: "Spanish", - KO: "Korean", -}; - -const DEFAULT = (_lang: string) => ""; -const HEADER: Record string> = { - speaking: (lang) => { - const name = LANG_CODE_NAMES[lang] || "the target language"; - return `How would you say this in ${name}?`; - }, - listening: () => "Translate this phrase to English", - dictation: (lang) => { - const name = LANG_CODE_NAMES[lang] || "the target language"; - return "Select difficulty and repeat in " + name; - }, -}; - -export const HOTKEYS = { - AGAIN: "a", - HARD: "s", - GOOD: "d", - EASY: "f", - PLAY: "v", - FLAG: "h", - DISAGREE: "k", - AGREE: "j", -}; - -const GRID_SIZE = 2; - -function StudyHeader(props: StudyHeaderProps) { - const { lessonType, langCode, isRecording } = props; - const text = (t: string) => { - return ( -
- {t} -
- ); - }; - - if (isRecording) { - return text("🎤 Record your response now"); - } - - const header = (HEADER[lessonType] || DEFAULT)(langCode); - - return text(header); -} - -const currentQuiz = (state: State) => { - const curr = state.currentItem; - if (curr.type !== "quiz") { - return; - } - return curr.value; -}; - -const assertQuiz: QuizAssertion = (q) => { - if (!q) { - return errorReport("No quiz found"); - } -}; - -function useBusinessLogic(state: State, dispatch: Dispatch) { - const flagCard = trpc.flagCard.useMutation(); - const gradeQuiz = trpc.gradeQuiz.useMutation(); - const getNextQuiz = trpc.getNextQuizzes.useMutation(); - const manuallyGade = trpc.manuallyGrade.useMutation(); - const userSettings = useUserSettings(); - const [perceivedDifficulty, setGrade] = useState(Grade.GOOD); - const quiz = currentQuiz(state); - const rollbackData = trpc.rollbackGrade.useMutation(); - const getPlaybackAudio = trpc.getPlaybackAudio.useMutation(); - const onRecord = async (audio: string) => { - assertQuiz(quiz); - const id = quiz.quizId; - dispatch({ type: "END_RECORDING", id: quiz.quizId }); - gradeQuiz - .mutateAsync({ id, audio, perceivedDifficulty }) - .then(async (data) => { - if (data.result === "fail") { - dispatch({ - type: "ADD_FAILURE", - value: { - id, - cardId: quiz.cardId, - term: quiz.term, - definition: quiz.definition, - lessonType: quiz.lessonType, - userTranscription: data.userTranscription, - rejectionText: data.rejectionText, - rollbackData: data.rollbackData, - playbackAudio: data.playbackAudio, - }, - }); - } - dispatch({ - type: "DID_GRADE", - id, - result: data.result, - }); - }) - .catch((error) => { - console.error(error, "Error grading quiz"); - dispatch({ - type: "DID_GRADE", - id, - result: "error", - }); - }) - .finally(() => { - const isFull = state.quizIDsForLesson.length >= 10; - getNextQuiz - .mutateAsync({ - notIn: [ - ...state.quizIDsForLesson, - ...state.idsAwaitingGrades, - ...state.idsWithErrors, - ], - take: isFull ? 1 : 7, - }) - .then((data) => { - dispatch({ - type: "ADD_MORE", - quizzes: data.quizzes, - totalCards: data.totalCards, - quizzesDue: data.quizzesDue, - newCards: data.newCards, - }); - }); - }); - }; - const { stop, start } = useVoiceRecorder(async (data) => { - assertQuiz(quiz); - const wav = await convertBlobToWav(data); - const b64 = await blobToBase64(wav); - if (quiz.lessonType !== "listening") { - if (Math.random() < userSettings.playbackPercentage) { - await playAudio(b64); - } - } - onRecord(b64); - }); - - return { - async playQuizAudio() { - assertQuiz(quiz); - await playAudio(quiz.audio); - }, - async flagQuiz() { - if (!confirm("This will pause reviews. Are you sure?")) { - return; - } - const curr = state.currentItem; - let id = 0; - switch (curr.type) { - case "quiz": - id = curr.value.cardId; - break; - case "failure": - id = curr.value.cardId; - break; - } - dispatch({ type: "FLAG_QUIZ", cardId: id }); - await flagCard.mutateAsync({ id: id }); - }, - async stopRecording() { - stop(); - }, - async startRecording(perceivedDifficulty: Grade) { - assertQuiz(quiz); - const id = quiz.quizId; - if (perceivedDifficulty === Grade.AGAIN) { - const { playbackAudio } = await getPlaybackAudio.mutateAsync({ id }); - dispatch({ type: "USER_GAVE_UP", id, playbackAudio }); - manuallyGade.mutateAsync({ - id, - grade: Grade.AGAIN, - }); - return; - } else { - setGrade(perceivedDifficulty); - dispatch({ type: "BEGIN_RECORDING" }); - await start(); - } - }, - async rollbackGrade() { - const curr = state.currentItem; - if (curr.type !== "failure") { - return errorReport("Not a failure?"); - } - const id = curr.value.id; - const schedulingData = curr.value.rollbackData; - schedulingData && - rollbackData.mutateAsync({ - id, - schedulingData, - }); - dispatch({ type: "REMOVE_FAILURE", id }); - }, - }; -} - -function useQuizState(props: QuizData) { - const cardsById = props.quizzes.reduce( - (acc, quiz) => { - acc[quiz.quizId] = quiz; - return acc; - }, - {} as Record, - ); - const newState = gotoNextQuiz( - newQuizState({ - cardsById, - totalCards: props.totalCards, - quizzesDue: props.quizzesDue, - newCards: props.newCards, - }), - ); - const [state, dispatch] = useReducer(quizReducer, newState); - // Include other state-related logic here, such as useEffects, and return necessary data and functions - return { - ...useBusinessLogic(state, dispatch), - state, - dispatch, - }; -} - -function NoQuizDue(_: {}) { - const settings = useUserSettings(); - - return ( -
-

No Cards Due

-

You can:

-
    -
  • - - Increase max cards per day (current value: {settings.cardsPerDayMax} - ) - -
  • -
  • - Create new cards. -
  • - {/*
  • - Import cards from a backup file -
  • */} -
  • Refresh this page to load more.
  • -
-
- ); -} - -function HotkeyButton(props: HotkeyButtonProps) { - return ( - - - - ); -} - -function QuizView(props: QuizViewProps) { - const { quiz } = props; - const [maxGrade, setMaxGrade] = useState(Grade.EASY); - // Reduce maxGrade after a timeout - // Cancel timer when quizID changes: - const quizID = props.quiz.quizId; - - useEffect(() => { - const timer = setTimeout(() => { - if (quiz.lessonType !== "dictation") { - setMaxGrade(maxGrade - 1); - } - }, 8000); - return () => { - clearTimeout(timer); - }; - }, [maxGrade, quizID]); - - useEffect(() => { - setMaxGrade(Grade.EASY); - props.playQuizAudio(); - }, [quizID]); - const gradeWith = (g: Grade) => async () => { - const grade = Math.min(g, maxGrade); - if (props.isRecording) { - await props.stopRecording(); - } else { - await props.startRecording(grade); - } - }; - useHotkeys([ - [HOTKEYS.PLAY, props.playQuizAudio], - [HOTKEYS.FLAG, props.flagQuiz], - [HOTKEYS.AGAIN, gradeWith(Grade.AGAIN)], - [HOTKEYS.HARD, gradeWith(Grade.HARD)], - [HOTKEYS.GOOD, gradeWith(Grade.GOOD)], - [HOTKEYS.EASY, gradeWith(Grade.EASY)], - ]); - const buttons: HotkeyButtonProps[] = [ - { - onClick: gradeWith(Grade.AGAIN), - label: "Again", - hotkey: HOTKEYS.AGAIN, - disabled: false, - }, - { - onClick: gradeWith(Grade.HARD), - label: "Hard", - hotkey: HOTKEYS.HARD, - disabled: false, - }, - { - onClick: gradeWith(Grade.GOOD), - label: "Good", - hotkey: HOTKEYS.GOOD, - disabled: maxGrade < Grade.GOOD, - }, - { - onClick: gradeWith(Grade.EASY), - label: "Easy", - hotkey: HOTKEYS.EASY, - disabled: maxGrade < Grade.EASY, - }, - ]; - const playAudio = () => { - setMaxGrade(maxGrade - 1); - props.playQuizAudio(); - }; - let buttonCluster = ( - - - - - - - - {buttons.map((p) => ( - - ))} - - ); - - if (props.isRecording) { - const keys = [HOTKEYS.AGAIN, HOTKEYS.HARD, HOTKEYS.GOOD, HOTKEYS.EASY]; - buttonCluster = ( - - - - - - ); - } - - const when = quiz.lastReview ? timeUntil(quiz.lastReview) : "never"; - return ( - <> - - {buttonCluster} - {quiz.lessonType === "dictation" &&

{quiz.term}

} -

Quiz #{quiz.quizId}

-

{quiz.repetitions} repetitions

-

{quiz.lapses} lapses

-

Last seen: {when}

-

- {props.totalCards} cards total, {props.quizzesDue} due, {props.newCards}{" "} - new, {props.awaitingGrades} awaiting grades, {props.queueSize} in study - Queue, - {props.pendingFailures} in failure queue,{" "} -

-

{linkToEditPage(quiz.cardId)}

- {quiz.imageURL && quiz} - - ); -} - -function LoadedStudyPage(props: QuizData) { - const everything = useQuizState(props); - const { state } = everything; - const curr = state.currentItem; - let el: JSX.Element; - switch (curr.type) { - case "quiz": - const quiz = curr.value; - const quizViewProps: QuizViewProps = { - quiz, - awaitingGrades: state.idsAwaitingGrades.length, - newCards: state.newCards, - queueSize: Object.keys(state.cardsById).length, - quizzesDue: state.quizzesDue, - totalCards: state.totalCards, - pendingFailures: state.failures.length, - isRecording: state.isRecording, - playQuizAudio: everything.playQuizAudio, - flagQuiz: everything.flagQuiz, - startRecording: everything.startRecording, - stopRecording: everything.stopRecording, - totalComplete: everything.state.totalComplete, - }; - el = ; - break; - case "failure": - const failure = curr.value; - el = ( - { - everything.dispatch({ type: "REMOVE_FAILURE", id: failure.id }); - }} - onFlag={everything.flagQuiz} - /> - ); - break; - case "none": - const willGrade = state.idsAwaitingGrades.length; - if (willGrade) { - el =
Waiting for the server to grade {willGrade} quizzes.
; - break; - } - el = ; - break; - case "loading": - el =
Loading...
; - break; - default: - return errorReport("Unexpected current item " + JSON.stringify(curr)); - } - return {el}; -} - -export default function StudyPage() { - const mutation = trpc.getNextQuizzes.useMutation(); - let el =
Loading...
; - useEffect(() => { - mutation.mutate({ notIn: [], take: 10 }); - }, []); - - if (mutation.isError) { - el =
Error occurred: {mutation.error.message}
; - } - - if (mutation.isSuccess) { - el = ; - } - - return MicrophonePermissions(el); -} diff --git a/pages/user.tsx b/pages/user.tsx index b236ee9..8d9027b 100644 --- a/pages/user.tsx +++ b/pages/user.tsx @@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications"; import { getUserSettingsFromEmail } from "@/koala/auth-helpers"; import { prismaClient } from "@/koala/prisma-client"; import { UnwrapPromise } from "@prisma/client/runtime/library"; -import { getLessonMeta } from "@/koala/routes/get-next-quizzes"; +import { getLessonMeta } from "@/koala/trpc-routes/get-next-quizzes"; const ONE_DAY = 24 * 60 * 60 * 1000; const ONE_WEEK = 7 * ONE_DAY;