Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve sort order of cards #30

Merged
merged 5 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,74 @@ It uses spaced repetition to help you learn more efficiently.
Note: This is project is still in early development.
If you have any suggestions or feedback, please feel free to open an issue.

## Motivation

Spaced repetition is one of the most effective ways to learn.
In fact, there are many great apps out there that use spaced repetition, such as
Anki, SuperMemo, RemNote, and Obsidian Spaced Repetition.

Personally, I use `obsidian-spaced-repetition`, which is great because it integrates with Obsidian.
I believe that markdown is the best way to store your notes.

However, I greatly dislike the separator syntax used to create flashcards,
for both `obsidian-spaced-repetition` and Obsidian to Anki.

For my own personal workflow, I want to be able to write notes in markdown that are easy to read.
Personally, I treat flashcards as separate entities that are _colocated_ with my notes,
but different in the following ways:

1. Flashcards are only seen during review.
2. There is no need to search / index / tag flashcards in the same way that notes are searched.
The whole point of flashcards is to only appear when they need to be reviewed.
3. The separator syntax adds a lot of clutter to my notes.

Thus, I created **spaced**, which focuses on handling the spaced repetition aspect of learning.
This way, I can focus on writing notes that are easy to read and understand (in Obsidian),
and create flashcards that are effective for spaced repetition (in spaced).

### Example

Here's an example of my existing notes using Obsidian Spaced Repetition:

```markdown
#### Dynamic and Static Binding

What is dynamic binding (aka late binding)?
?
A mechanism where _method calls_ in code are resolved at **runtime** rather than at compile time.

<!--SR:!2024-01-02,25,249-->

What is static binding?
?
When a _method call_ is resolved at _compile_ time.

<!--SR:!2024-03-24,94,271-->

[[#Method Overriding|Overriden]] and [[#Method Overloading|overloaded]] methods: static or dynamic binding?
?
Overridden methods are resolved using **dynamic binding**, and therefore resolves to the implementation in the actual type of the object.
In contrast, overloaded methods are resolved using **static binding**.

<!--SR:!2024-03-12,85,271-->
```

Here's my ideal syntax:

```markdown
#### Dynamic and Static Binding

**Dynamic Binding (Late Binding)**: A mechanism where _method calls_ in code are resolved at **runtime** rather than at compile time.
Override methods are resolved using **dynamic binding**.

<!-- id:<some-card-id-here, where flashcards are stored separately> -->

**Static binding**: When a _method call_ is resolved at _compile_ time.
Overloaded methods are resolved using **static binding**.

<!-- id:<some-card-id-here, where flashcards are stored separately> -->
```

## Resources

- [SuperMemo's Twenty Rules of Formulating Knowledge](https://www.supermemo.com/en/blog/twenty-rules-of-formulating-knowledge)
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.7",
"immer": "^10.0.4",
"lodash": "^4.17.21",
"lucide-react": "^0.365.0",
"next": "14.1.4",
"next-themes": "^0.3.0",
Expand All @@ -51,6 +52,7 @@
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
"@types/lodash": "^4.17.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 33 additions & 1 deletion src/components/flashcard/flashcard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import {
UiCardHeader,
UiCardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import EditableTextarea from "@/components/ui/editable-textarea";
import { Toggle } from "@/components/ui/toggle";
import { useDeleteCard } from "@/hooks/card/use-delete-card";
Expand All @@ -30,7 +38,8 @@ import { useClickOutside } from "@/hooks/use-click-outside";
import useKeydownRating from "@/hooks/use-keydown-rating";
import { CardContent, Rating, type Card } from "@/schema";
import { cn } from "@/utils/ui";
import { EyeIcon, FilePenIcon, TrashIcon } from "lucide-react";
import _ from "lodash";
import { EyeIcon, FilePenIcon, Info, TrashIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";

type Props = {
Expand Down Expand Up @@ -114,6 +123,29 @@ export default function Flashcard({
>
<FilePenIcon className="h-4 w-4" strokeWidth={1.5} />
</Toggle>

<Dialog>
<DialogTrigger
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
)}
>
<Info className="h-4 w-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Stats</DialogTitle>
{Object.entries(card).map(([k, v]) => {
return (
<DialogDescription key={k}>
{_.upperFirst(k)}: {v?.toString()}
</DialogDescription>
);
})}
</DialogHeader>
</DialogContent>
</Dialog>

<AlertDialog>
<AlertDialogTrigger
className={cn(
Expand Down
122 changes: 122 additions & 0 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";

import { cn } from "@/utils/ui";

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = DialogPrimitive.Portal;

const DialogClose = DialogPrimitive.Close;

const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";

const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";

const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
35 changes: 16 additions & 19 deletions src/hooks/card/use-grade-card.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getReviewDateForEachRating, gradeCard } from "@/utils/fsrs";
import Sorts from "@/utils/sort";
import { ReactQueryOptions, trpc } from "@/utils/trpc";
import { endOfDay, isBefore } from "date-fns";
import { produce } from "immer";
Expand Down Expand Up @@ -33,34 +34,30 @@ export function useGradeCard(options?: GradeMutationOptions): GradeMutation {
const reviewDay = getReviewDateForEachRating(card.cards);
const day = reviewDay[grade];
const isCardToBeReviewedAgainToday = isBefore(day, endOfDay(new Date()));
const nextCard = gradeCard(card.cards, grade);
const { nextCard } = gradeCard(card.cards, grade);

// Update the cards in the cache
const nextCards = produce(allCards, (draft) => {
const cardIndex = draft.findIndex((card) => card.cards.id === id);
const cardNotFound = cardIndex === -1;
if (cardNotFound) return;
if (!isCardToBeReviewedAgainToday) {
// We're allowed to return in Immer
// https://immerjs.github.io/immer/return
return draft.filter((card) => card.cards.id !== id);
}

draft[cardIndex].cards = {
...draft[cardIndex].cards,
...nextCard,
};
draft.sort((a, b) => (isBefore(a.cards.due, b.cards.due) ? -1 : 1));
return;
});
const allCardsWithoutGradedCard = allCards.filter(
(card) => card.cards.id !== id,
);
const nextCards = isCardToBeReviewedAgainToday
? [
...allCardsWithoutGradedCard,
{
cards: nextCard,
card_contents: card.card_contents,
},
]
: allCardsWithoutGradedCard;
nextCards.sort((a, b) => Sorts.DIFFICULTY_ASC.fn(a.cards, b.cards));
utils.card.all.setData(undefined, nextCards);

// Update the stats in the cache
const stats = utils.card.stats.getData();
if (!stats) {
return { previousCards: allCards };
}

// Update the stats in the cache
const nextStats = produce(stats, (draft) => {
switch (card.cards.state) {
case "New":
Expand Down
4 changes: 4 additions & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type ReviewLog = typeof reviewLogs.$inferSelect;
export type NewReviewLog = typeof reviewLogs.$inferInsert;

// * For now we just copy the schema from the ts-fsrs-demo example
// Note that some fields use snake case here for compatiblity with the ts-fsrs library
// TODO standardise to using camelCase and write a converter
export const cards = sqliteTable("cards", {
id: text("id").primaryKey(),
due: integer("due", { mode: "timestamp" })
Expand All @@ -71,6 +73,8 @@ export const cards = sqliteTable("cards", {
.notNull()
.default(sql`(unixepoch())`),
});
// Benchmark performance to check if we should use indexes for difficulty and due
// columns.

export type Card = typeof cards.$inferSelect;
export type NewCard = typeof cards.$inferInsert;
Expand Down
Loading