Skip to content

Commit

Permalink
feat: implement UI and e2e functionality for flashcards v1
Browse files Browse the repository at this point in the history
  • Loading branch information
Shunseii committed Jul 6, 2024
1 parent af4f381 commit 3b22f44
Show file tree
Hide file tree
Showing 15 changed files with 6,567 additions and 4,843 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@upstash/ratelimit": "^1.2.0",
"@upstash/redis": "^1.28.4",
"ajv": "^8.16.0",
"ajv-formats": "^3.0.1",
"arctic": "^1.2.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/routers/dictionary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import express, {
type NextFunction,
} from "express";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import multer from "multer";

import schema from "../schema.json";
Expand Down Expand Up @@ -54,6 +55,8 @@ dictionaryRouter.post(
uploadWithErrorHandling,
async (req, res) => {
const ajv = new Ajv({ allErrors: false });
addFormats(ajv, ["date-time"]);

const fileData = req.file?.buffer.toString("utf-8");

if (!fileData) {
Expand Down
34 changes: 21 additions & 13 deletions apps/api/src/routers/flashcard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import { meilisearchClient } from "../clients/meilisearch";
import { Card, createEmptyCard } from "ts-fsrs";
import { z } from "zod";

type Flashcard = Card & {
export type Flashcard = Card & {
id: string;
content: string;
translation: string;
due: string;
last_review: string;
last_review: string | null;
due_timestamp: number;
last_review_timestamp: number;
last_review_timestamp: number | null;
};

const FlashcardSchema = z.object({
id: z.string(),
content: z.string(),
translation: z.string(),
elapsed_days: z.number(),
lapses: z.number(),
reps: z.number(),
Expand All @@ -39,23 +43,27 @@ export const flashcardRouter = router({
/**
* Current timestamp in seconds
*/
// const now = Math.floor(new Date().getTime() / 1000);
const now = Math.floor(new Date().getTime() / 1000);

const { results } = await meilisearchClient.index(user.id).getDocuments({
filter: [
`flashcard.due_timestamp EXISTS`,
// `flashcard.due_timestamp NOT EXISTS OR flashcard.due_timestamp <= ${now}`,
`flashcard.due_timestamp NOT EXISTS OR flashcard.due_timestamp <= ${now}`,
],
limit: 1000,
});

const flashcards: Flashcard[] = results.map(({ id, flashcard }) => {
const card = flashcard ?? getEmptyFlashcard(id);
const flashcards: Flashcard[] = results.map(
({ id, word, translation, flashcard }) => {
const card = flashcard ?? getEmptyFlashcard(id);

return {
id,
...card,
};
});
return {
id,
content: word,
translation,
...card,
};
},
);

return {
flashcards,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"ajv": "^8.16.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.2.11",
"input-otp": "0.3.31-beta",
"jotai": "^2.7.0",
Expand All @@ -49,6 +50,7 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"ts-fsrs": "^3.5.7",
"vaul": "^0.9.1",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
258 changes: 258 additions & 0 deletions apps/web/src/components/FlashcardDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { Plural, Trans } from "@lingui/macro";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { Button } from "./ui/button";
import { formatDistanceToNow } from "date-fns";
import { ar } from "date-fns/locale/ar";
import { enUS } from "date-fns/locale/en-US";
import { fsrs, Grade, Rating } from "ts-fsrs";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
} from "./ui/drawer";
import {
FC,
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { trpc } from "@/lib/trpc";
import { queryClient } from "@/lib/query";
import { getQueryKey } from "@trpc/react-query";
import { motion } from "framer-motion";
import { useDir } from "@/hooks/useDir";

export const FlashcardDrawer: FC<PropsWithChildren> = ({ children }) => {
const dir = useDir();
const [showAnswer, setShowAnswer] = useState(false);
const { data, status } = trpc.flashcard.today.useQuery();
const { mutate: updateFlashcard } = trpc.flashcard.update.useMutation({
onSuccess: async () => {
const queryKey = [
...getQueryKey(trpc.flashcard.today),
{ type: "query" },
];

await queryClient.invalidateQueries({ queryKey });
},
});

const flashcards = data?.flashcards ?? [];

const [currentCard, setCurrentCard] = useState<(typeof flashcards)[0] | null>(
flashcards[0],
);

useEffect(() => {
if (!flashcards?.length) return;

setCurrentCard(flashcards[0]);
}, [flashcards]);

useEffect(() => {
if (currentCard) {
setShowAnswer(false);
}
}, [currentCard]);

const f = useMemo(() => fsrs(), []);
const now = new Date();

const scheduling_cards = currentCard ? f.repeat(currentCard, now) : undefined;
const currentFlashcardIndex = flashcards.findIndex(
(flashcard) => flashcard.id === currentCard?.id,
);

const gradeCard = useCallback(
(grade: Grade) => {
if (!scheduling_cards || !currentCard) return;

const selectedCard = scheduling_cards[grade].card;
const dueTimestamp = Math.floor(selectedCard.due.getTime() / 1000);
const lastReviewTimestamp = selectedCard?.last_review
? Math.floor(selectedCard.last_review.getTime() / 1000)
: null;

const newCard = {
...selectedCard,
id: currentCard.id,
content: currentCard.content,
translation: currentCard.translation,
due: selectedCard.due.toISOString(),
last_review: selectedCard?.last_review?.toISOString() ?? null,
due_timestamp: dueTimestamp,
last_review_timestamp: lastReviewTimestamp,
};

updateFlashcard(newCard);

if (currentFlashcardIndex === flashcards.length - 1) {
setCurrentCard(null);
}
},
[currentCard],
);

// Initial load
if (status === "pending") {
return null;
}

return (
<Drawer
onClose={() => {
setShowAnswer(false);
}}
>
{flashcards?.length ? (
<Tooltip>
<TooltipTrigger asChild>
<DrawerTrigger asChild>{children}</DrawerTrigger>
</TooltipTrigger>

<TooltipContent>
<Plural
value={flashcards.length}
one="You have # card to review."
other="You have # cards to review"
/>
</TooltipContent>
</Tooltip>
) : (
<DrawerTrigger asChild>{children}</DrawerTrigger>
)}

<DrawerContent>
<DrawerHeader>
<DrawerTitle>
{flashcards?.length ? (
<Plural
value={flashcards.length}
one="# card left to review"
other="# cards left to review"
/>
) : (
<Trans>You have no flashcards to review for now!</Trans>
)}
</DrawerTitle>
</DrawerHeader>

{!currentCard ? undefined : (
<div className="w-full max-w-2xl mx-auto flex flex-col gap-y-4 px-8">
<p dir="rtl" className="rtl:text-right text-xl sm:text-2xl">
{currentCard.content}
</p>

{showAnswer && (
<motion.p
dir="ltr"
className="ltr:text-left text-base sm:text-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{currentCard.translation}
</motion.p>
)}
</div>
)}

<DrawerFooter>
{(() => {
if (scheduling_cards && showAnswer) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex gap-x-6 items-center w-max m-auto"
>
<div className="flex flex-col gap-y-2 items-center justify-center">
<p className="ltr:text-sm rtl:text-base ltr:font-light rtl:font-normal">
{formatDistanceToNow(
scheduling_cards[Rating.Again].card.due,
{ locale: dir === "ltr" ? enUS : ar },
)}
</p>

<Button
variant="outline"
onClick={() => gradeCard(Rating.Again)}
className="rtl:text-lg"
>
<Trans>Again</Trans>
</Button>
</div>

<div className="flex flex-col gap-y-2 items-center justify-center">
<p className="ltr:text-sm rtl:text-base ltr:font-light rtl:font-normal">
{formatDistanceToNow(
scheduling_cards[Rating.Hard].card.due,
{ locale: dir === "ltr" ? enUS : ar },
)}
</p>

<Button
variant="destructive"
onClick={() => gradeCard(Rating.Hard)}
className="rtl:text-lg"
>
<Trans>Hard</Trans>
</Button>
</div>

<div className="flex flex-col gap-y-2 items-center justify-center">
<p className="ltr:text-sm rtl:text-base ltr:font-light rtl:font-normal">
{formatDistanceToNow(
scheduling_cards[Rating.Good].card.due,
{ locale: dir === "ltr" ? enUS : ar },
)}
</p>

<Button
variant="secondary"
onClick={() => gradeCard(Rating.Good)}
className="rtl:text-lg"
>
<Trans>Good</Trans>
</Button>
</div>

<div className="flex flex-col gap-y-2 items-center justify-center">
<p className="ltr:text-sm rtl:text-base ltr:font-light rtl:font-normal">
{formatDistanceToNow(
scheduling_cards[Rating.Easy].card.due,
{ locale: dir === "ltr" ? enUS : ar },
)}
</p>

<Button
className="bg-green-600 hover:bg-green-500/60 rtl text-lg"
onClick={() => gradeCard(Rating.Easy)}
>
<Trans>Easy</Trans>
</Button>
</div>
</motion.div>
);
} else if (scheduling_cards) {
return (
<Button
className="w-full max-w-sm self-center rtl:text-lg"
onClick={() => setShowAnswer(true)}
>
<Trans>Show answer</Trans>
</Button>
);
} else {
return null;
}
})()}
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
Loading

0 comments on commit 3b22f44

Please sign in to comment.