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

[wordvault] Use recall rate instead of lapses, refactor stats code structure #508

Closed
Closed
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
4 changes: 4 additions & 0 deletions frontend/src/date_string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const dateString = (datestr: string, showTime?: boolean) =>
`${new Date(datestr).toLocaleDateString()}${
showTime ? " " + new Date(datestr).toLocaleTimeString() : ""
}`;
8 changes: 4 additions & 4 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Notifications } from "@mantine/notifications";
import WordSearch from "./search/word_search.tsx";
import CardSchedule from "./schedule.tsx";
import Help from "./help.tsx";
import CardStats from "./card_stats.tsx";
import Statistics from "./stats/statistics_page.tsx";
import Settings from "./settings.tsx";
import Leaderboard from "./leaderboard.tsx";

Expand Down Expand Up @@ -51,7 +51,7 @@ const router = createBrowserRouter(
},
{
path: "/stats",
element: <CardStats />,
element: <Statistics />,
},
{
path: "/leaderboard",
Expand All @@ -70,7 +70,7 @@ const router = createBrowserRouter(
],
{
basename: "/wordvault",
}
},
);

const theme = createTheme({
Expand All @@ -93,5 +93,5 @@ createRoot(document.getElementById("root")!).render(
<Notifications position="top-center" />
<RouterProvider router={router} />
</MantineProvider>
</StrictMode>
</StrictMode>,
);
21 changes: 12 additions & 9 deletions frontend/src/previous_card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Timestamp } from "@bufbuild/protobuf";
import { Score } from "./gen/rpc/wordvault/api_pb";
import React, { useState } from "react";
import { Alert, Button, Group, Popover, Stack, Text } from "@mantine/core";
import { CardRecallStats } from "./stats/card_recall_stats";
import { CardRecallStat, ParsedFsrsCardStats } from "./stats/types";

export interface HistoryEntry {
score: Score;
alphagram: string;
nextScheduled: Timestamp;
cardRepr: { [key: string]: string };
previousCardRepr: { [key: string]: string };
cardRepr: ParsedFsrsCardStats;
previousCardRepr: ParsedFsrsCardStats;
}

interface PreviousCardProps {
Expand All @@ -33,6 +35,14 @@ const PreviousCard: React.FC<PreviousCardProps> = ({
<Text size="md" fw={500}>
{entry.alphagram}
</Text>
<CardRecallStats
card={entry.cardRepr}
textProps={{
size: "sm",
c: "dimmed",
}}
excludeStats={new Set([CardRecallStat.LAST_SEEN])}
/>
<Text size="sm" c="dimmed">
Score:{" "}
<Text
Expand All @@ -43,13 +53,6 @@ const PreviousCard: React.FC<PreviousCardProps> = ({
{Score[entry.score] === "AGAIN" ? "MISSED" : Score[entry.score]}
</Text>
</Text>
<Text size="sm" c="dimmed">
Next Due Date: {entry.nextScheduled.toDate().toLocaleDateString()}
</Text>
<Text size="sm" c="dimmed">
Times Seen: {entry.cardRepr["Reps"]}
</Text>
<Text>Times Forgotten: {entry.cardRepr["Lapses"]}</Text>
</Stack>
<Popover
trapFocus
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/stats/card_recall_stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { dateString } from "../date_string";
import { CardRecallStat, ParsedFsrsCardStats } from "./types";
import { Text, TextProps } from "@mantine/core";

export interface CardRecallStatsProps {
card: ParsedFsrsCardStats;
showTime?: boolean;
textProps?: Exclude<TextProps, "component">;
valueProps?: Exclude<TextProps, "component">;
excludeStats?: Set<CardRecallStat>;
}

export function CardRecallStats({
card,
textProps,
valueProps,
excludeStats = new Set(),
showTime = false,
}: CardRecallStatsProps) {
// The first time a card is incorrectly answered is not logged as a
// lapse, so we exclude that from the calculation of recall rate.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not entirely true. If you answer it incorrectly after getting it right the very first time you see it, that counts as a lapse. If you answer it incorrectly the first time, and then answer it correctly the second time, you have no lapses. So subtracting 1 always is not quite right.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh I see, in that case I don't think we can get to recall % without a back-end change as well, right? probably not worth the effort then relative to other tasks I was gonna take a look at

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I don't think we can get recall % without keeping track of the actual number of times the question has been answered wrong. The best way I can think of getting this is from the log array that gets stored with each question, and just counting the incorrect entries. Or we can cache that number in that JSON blob too. But probably not worth it for right now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looping back, how certain are you that this is correct?

If you answer it incorrectly the first time, and then answer it correctly the second time, you have no lapses

I'm pretty certain I have a non-zero number of cards I've never gotten right that show Times forgotten: {times seen - 1}

const timesRecalled = card.Reps - 1 - card.Lapses;

let recallPercentageDisplay: string = "N/A";
if (card.Reps > 1) {
const recallPercentage = (timesRecalled / (card.Reps - 1)) * 100;
recallPercentageDisplay =
recallPercentage.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 0,
}) + "%";
}

return (
<>
{!excludeStats.has(CardRecallStat.DUE_DATE) && (
<Text {...textProps}>
Next Due Date:{" "}
<Text component="span" {...valueProps}>
{dateString(card.Due, showTime)}
</Text>
</Text>
)}
{!excludeStats.has(CardRecallStat.LAST_SEEN) && (
<Text {...textProps}>
Last Seen:{" "}
<Text component="span" {...valueProps}>
{dateString(card.LastReview, showTime)}
</Text>
</Text>
)}
{!excludeStats.has(CardRecallStat.TIMES_SEEN) && (
<Text {...textProps}>
Times Seen:{" "}
<Text component="span" {...valueProps}>
{card.Reps}
</Text>
</Text>
)}
{!excludeStats.has(CardRecallStat.RECALL_RATE) && (
<Text {...textProps}>
Recall Rate:{" "}
<Text component="span" {...valueProps}>
{recallPercentageDisplay}
</Text>
</Text>
)}
</>
);
}
104 changes: 31 additions & 73 deletions frontend/src/card_stats.tsx → frontend/src/stats/statistics_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@ import React, {
useMemo,
useState,
} from "react";
import { AppContext } from "./app_context";
import { WordVaultService } from "./gen/rpc/wordvault/api_connect";
import { useClient } from "./use_client";
import { AppContext } from "../app_context";
import { WordVaultService } from "../gen/rpc/wordvault/api_connect";
import { useClient } from "../use_client";
import {
Button,
Card,
Divider,
List,
Stack,
Text,
TextInput,
Expand All @@ -22,7 +21,7 @@ import {
useMantineTheme,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Score, Card as WordVaultCard } from "./gen/rpc/wordvault/api_pb";
import { Score, Card as WordVaultCard } from "../gen/rpc/wordvault/api_pb";
import {
IconAlertHexagon,
IconBabyBottle,
Expand All @@ -32,9 +31,12 @@ import {
IconX,
} from "@tabler/icons-react";
import { BarChart, LineChart } from "@mantine/charts";
import { getBrowserTimezone } from "./timezones";
import { getBrowserTimezone } from "../timezones";
import { ParsedFsrsCardStats } from "./types";
import { CardRecallStats } from "./card_recall_stats";
import { dateString } from "../date_string";

const CardStats: React.FC = () => {
const StatisticsPage: React.FC = () => {
const { lexicon, jwt } = useContext(AppContext);
const [lookup, setLookup] = useState("");
const wordvaultClient = useClient(WordVaultService);
Expand Down Expand Up @@ -86,20 +88,20 @@ const CardStats: React.FC = () => {
() =>
cardInfo
? (JSON.parse(
new TextDecoder().decode(cardInfo?.cardJsonRepr)
) as fsrsCard)
new TextDecoder().decode(cardInfo?.cardJsonRepr),
) as ParsedFsrsCardStats)
: null,
[cardInfo]
[cardInfo],
);

const reviewLog = useMemo(
() =>
cardInfo
? (JSON.parse(
new TextDecoder().decode(cardInfo?.reviewLog)
new TextDecoder().decode(cardInfo?.reviewLog),
) as reviewLogItem[])
: null,
[cardInfo]
[cardInfo],
);

return (
Expand All @@ -121,7 +123,7 @@ const CardStats: React.FC = () => {
.toLocaleUpperCase()
.split("")
.sort()
.join("")
.join(""),
)
}
></TextInput>
Expand All @@ -138,16 +140,6 @@ const CardStats: React.FC = () => {
);
};

type fsrsCard = {
Due: string;
Stability: number;
Difficulty: number;
Reps: number;
Lapses: number;
State: number;
LastReview: string;
};

type importLog = {
ImportedDate: string;
CardboxAtImport: number;
Expand All @@ -161,7 +153,7 @@ type reviewLogItem = {
};

interface CardInfoProps {
fsrsCard: fsrsCard;
fsrsCard: ParsedFsrsCardStats;
reviewLog: reviewLogItem[];
cardInfo: WordVaultCard;
}
Expand All @@ -174,13 +166,6 @@ const CardInfo: React.FC<CardInfoProps> = ({
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === "dark";
const dstr = (datestr: string, showTime?: boolean) =>
`${new Date(datestr).toLocaleDateString()}${
showTime ? " " + new Date(datestr).toLocaleTimeString() : ""
}`;

const dueDate = dstr(fsrsCard.Due, true);
const lastReview = dstr(fsrsCard.LastReview, true);

const forgettingCurve = useMemo(() => {
// return math.Pow(1+factor*elapsedDays/stability, decay)
Expand Down Expand Up @@ -218,46 +203,19 @@ const CardInfo: React.FC<CardInfoProps> = ({
<Text size="xl" fw={700} ta="center" c={theme.colors.blue[4]}>
{cardInfo.alphagram?.alphagram.toUpperCase()}
</Text>
<List spacing="xs" withPadding>
<List.Item>
<Text c={isDark ? theme.colors.gray[4] : theme.colors.gray[9]}>
Next due:{" "}
<Text component="span" fw={500}>
{dueDate}
</Text>
</Text>
</List.Item>
<List.Item>
<Text c={isDark ? theme.colors.gray[4] : theme.colors.gray[9]}>
Last seen:{" "}
<Text component="span" fw={500}>
{lastReview}
</Text>
</Text>
</List.Item>
<List.Item>
<Text c={isDark ? theme.colors.gray[4] : theme.colors.gray[9]}>
Number of times asked:{" "}
<Text component="span" fw={500}>
{fsrsCard.Reps}
</Text>
</Text>
</List.Item>
<List.Item>
<Text c={isDark ? theme.colors.gray[4] : theme.colors.gray[9]}>
Number of times forgotten:{" "}
<Text component="span" fw={500}>
{fsrsCard.Lapses}
</Text>
</Text>
</List.Item>
</List>

<Text size="lg" fw={700} mt="md" c={theme.colors.blue[4]}>
FSRS values
</Text>

<Stack gap="xs">
<CardRecallStats
card={fsrsCard}
textProps={{ size: "lg" }}
valueProps={{ fw: 500 }}
showTime
/>

<Text size="lg" fw={700} mt="md" c={theme.colors.blue[4]}>
FSRS values
</Text>

<Text>
<Tooltip
multiline
Expand Down Expand Up @@ -332,7 +290,7 @@ const CardInfo: React.FC<CardInfoProps> = ({
dataKey="date"
yAxisLabel="Recall"
series={[{ name: "Recall", color: "blue" }]}
referenceLines={[{ x: dstr(fsrsCard.Due), label: "Next review" }]}
referenceLines={[{ x: dateString(fsrsCard.Due), label: "Next review" }]}
yAxisProps={{ domain: [0, 100] }}
unit="%"
withDots={false}
Expand All @@ -358,7 +316,7 @@ const CardInfo: React.FC<CardInfoProps> = ({
return (
<Timeline.Item title="Imported" key="import" bullet={bullet}>
<Text size="xs" c={theme.colors.gray[6]}>
{`${dstr(rl.ImportLog.ImportedDate, true)} from cardbox ${
{`${dateString(rl.ImportLog.ImportedDate, true)} from cardbox ${
rl.ImportLog.CardboxAtImport
}`}
</Text>
Expand Down Expand Up @@ -387,7 +345,7 @@ const CardInfo: React.FC<CardInfoProps> = ({
bullet={bullet}
>
<Text size="xs" c={theme.colors.gray[6]}>
{dstr(rl.Review, true)}
{dateString(rl.Review, true)}
</Text>
</Timeline.Item>
);
Expand Down Expand Up @@ -468,4 +426,4 @@ const TodayStats: React.FC<TodayStatsProps> = ({ stats }) => {
);
};

export default CardStats;
export default StatisticsPage;
18 changes: 18 additions & 0 deletions frontend/src/stats/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type ParsedFsrsCardStats = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we can get this type def from the back-end codegen, but based on how things were being implement right now I assume not

Difficulty: number;
Due: string;
ElapsedDays: number;
Lapses: number;
LastReview: string;
Reps: number;
ScheduledDays: number;
Stability: number;
State: number;
};

export enum CardRecallStat {
DUE_DATE,
TIMES_SEEN,
RECALL_RATE,
LAST_SEEN,
}