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

Feat/forgetting curve in card info #3437

Merged
merged 18 commits into from
Sep 27, 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
6 changes: 6 additions & 0 deletions ftl/core/card-stats.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ card-stats-review-log-type-review = Review
card-stats-review-log-type-relearn = Relearn
card-stats-review-log-type-filtered = Filtered
card-stats-review-log-type-manual = Manual
card-stats-review-log-elapsed-time = Elapsed Time
card-stats-no-card = (No card to display.)
card-stats-custom-data = Custom Data
card-stats-fsrs-stability = Stability
card-stats-fsrs-difficulty = Difficulty
card-stats-fsrs-retrievability = Retrievability
card-stats-fsrs-forgetting-curve-title = Forgetting Curve
card-stats-fsrs-forgetting-curve-first-week = First Week
card-stats-fsrs-forgetting-curve-first-month = First Month
card-stats-fsrs-forgetting-curve-first-year = First Year
card-stats-fsrs-forgetting-curve-all-time = All Time

## Window Titles

Expand Down
1 change: 1 addition & 0 deletions proto/anki/stats.proto
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ message CardStatsResponse {
// per mill
uint32 ease = 5;
float taken_secs = 6;
optional cards.FsrsMemoryState memory_state = 7;
}
repeated StatsRevlogEntry revlog = 1;
int64 card_id = 2;
Expand Down
45 changes: 44 additions & 1 deletion rslib/src/stats/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use crate::card::CardQueue;
use crate::card::CardType;
use crate::prelude::*;
use crate::revlog::RevlogEntry;
use crate::scheduler::fsrs::memory_state::single_card_revlog_to_item;
use crate::scheduler::fsrs::weights::ignore_revlogs_before_ms_from_config;
use crate::scheduler::timing::is_unix_epoch_timestamp;

impl Collection {
Expand Down Expand Up @@ -70,7 +72,7 @@ impl Collection {
total_secs,
card_type: nt.get_template(card.template_idx)?.name.clone(),
notetype: nt.name.clone(),
revlog: revlog.iter().rev().map(stats_revlog_entry).collect(),
revlog: self.stats_revlog_entries_with_memory_state(&card, revlog)?,
memory_state: card.memory_state.map(Into::into),
fsrs_retrievability,
custom_data: card.custom_data,
Expand Down Expand Up @@ -113,6 +115,46 @@ impl Collection {
),
})
}

fn stats_revlog_entries_with_memory_state(
self: &mut Collection,
card: &Card,
revlog: Vec<RevlogEntry>,
) -> Result<Vec<anki_proto::stats::card_stats_response::StatsRevlogEntry>> {
let deck_id = card.original_deck_id.or(card.deck_id);
let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?;
let conf_id = DeckConfigId(deck.normal()?.config_id);
let config = self
.storage
.get_deck_config(conf_id)?
.or_not_found(conf_id)?;
let historical_retention = config.inner.historical_retention;
let fsrs = FSRS::new(Some(&config.inner.fsrs_weights))?;
let next_day_at = self.timing_today()?.next_day_at;
let ignore_before = ignore_revlogs_before_ms_from_config(&config)?;

let mut result = Vec::new();
let mut accumulated_revlog = Vec::new();

for entry in revlog {
accumulated_revlog.push(entry.clone());
let item = single_card_revlog_to_item(
&fsrs,
accumulated_revlog.clone(),
next_day_at,
historical_retention,
ignore_before,
)?;
let mut card_clone = card.clone();
card_clone.set_memory_state(&fsrs, item, historical_retention)?;

let mut stats_entry = stats_revlog_entry(&entry);
stats_entry.memory_state = card_clone.memory_state.map(Into::into);
result.push(stats_entry);
}

Ok(result.into_iter().rev().collect())
}
}

fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) {
Expand All @@ -138,6 +180,7 @@ fn stats_revlog_entry(
interval: entry.interval_secs(),
ease: entry.ease_factor,
taken_secs: entry.taken_millis as f32 / 1000.,
memory_state: None,
}
}

Expand Down
9 changes: 8 additions & 1 deletion ts/routes/card-info/CardInfo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import CardInfoPlaceholder from "./CardInfoPlaceholder.svelte";
import CardStats from "./CardStats.svelte";
import Revlog from "./Revlog.svelte";
import ForgettingCurve from "./ForgettingCurve.svelte";

export let stats: CardStatsResponse | null = null;
export let showRevlog: boolean = true;
export let fsrsEnabled: boolean = stats?.memoryState != null;
</script>

<Container breakpoint="md" --gutter-inline="1rem" --gutter-block="0.5rem">
Expand All @@ -24,7 +26,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

{#if showRevlog}
<Row>
<Revlog revlog={stats.revlog} />
<Revlog revlog={stats.revlog} {fsrsEnabled} />
</Row>
{/if}
{#if fsrsEnabled}
<Row>
<ForgettingCurve revlog={stats.revlog} />
</Row>
{/if}
{:else}
Expand Down
85 changes: 85 additions & 0 deletions ts/routes/card-info/ForgettingCurve.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { type CardStatsResponse_StatsRevlogEntry as RevlogEntry } from "@generated/anki/stats_pb";
import * as tr from "@generated/ftl";
import Graph from "../graphs/Graph.svelte";
import NoDataOverlay from "../graphs/NoDataOverlay.svelte";
import AxisTicks from "../graphs/AxisTicks.svelte";
import { writable } from "svelte/store";
import InputBox from "../graphs/InputBox.svelte";
import { prepareData, renderForgettingCurve, TimeRange } from "./forgetting-curve";
import { defaultGraphBounds } from "../graphs/graph-helpers";
import HoverColumns from "../graphs/HoverColumns.svelte";

export let revlog: RevlogEntry[];
let svg = null as HTMLElement | SVGElement | null;
const bounds = defaultGraphBounds();
const timeRange = writable(TimeRange.AllTime);
const title = tr.cardStatsFsrsForgettingCurveTitle();
const data = prepareData(revlog, TimeRange.AllTime);

$: renderForgettingCurve(revlog, $timeRange, svg as SVGElement, bounds);
</script>

<div class="forgetting-curve">
<InputBox>
<div class="time-range-selector">
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Week} />
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
</label>
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Month} />
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
</label>
{#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.Year}
/>
{tr.cardStatsFsrsForgettingCurveFirstYear()}
</label>
{/if}
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.AllTime} />
{tr.cardStatsFsrsForgettingCurveAllTime()}
</label>
</div>
</InputBox>
<Graph {title}>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<AxisTicks {bounds} />
<HoverColumns />
<NoDataOverlay {bounds} />
</svg>
</Graph>
</div>

<style>
.forgetting-curve {
width: 100%;
max-width: 50em;
}

.time-range-selector {
display: flex;
justify-content: space-around;
margin-bottom: 1em;
width: 100%;
max-width: 50em;
}

.time-range-selector label {
display: flex;
align-items: center;
}

.time-range-selector input {
margin-right: 0.5em;
}
</style>
32 changes: 30 additions & 2 deletions ts/routes/card-info/Revlog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { RevlogEntry_ReviewKind as ReviewKind } from "@generated/anki/stats_pb";
import * as tr2 from "@generated/ftl";
import { timeSpan, Timestamp } from "@tslib/time";
import { filterRevlogByReviewKind } from "./forgetting-curve";

export let revlog: RevlogEntry[];
export let fsrsEnabled: boolean = false;

function reviewKindClass(entry: RevlogEntry): string {
switch (entry.reviewKind) {
Expand Down Expand Up @@ -54,9 +56,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
interval: string;
ease: string;
takenSecs: string;
elapsedTime: string;
stability: string;
}

function revlogRowFromEntry(entry: RevlogEntry): RevlogRow {
function revlogRowFromEntry(entry: RevlogEntry, elapsedTime: string): RevlogRow {
const timestamp = new Timestamp(Number(entry.time));

return {
Expand All @@ -69,10 +73,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
interval: timeSpan(entry.interval),
ease: formatEaseOrDifficulty(entry.ease),
takenSecs: timeSpan(entry.takenSecs, true),
elapsedTime,
stability: entry.memoryState?.stability
? timeSpan(entry.memoryState.stability * 86400)
: "",
};
}

$: revlogRows = revlog.map(revlogRowFromEntry);
$: revlogRows = revlog.map((entry, index) => {
let prevValidEntry: RevlogEntry | undefined;
let i = index + 1;
while (i < revlog.length) {
if (filterRevlogByReviewKind(revlog[i])) {
prevValidEntry = revlog[i];
break;
}
i++;
}

let elapsedTime = "N/A";
if (filterRevlogByReviewKind(entry)) {
elapsedTime = prevValidEntry
? timeSpan(Number(entry.time) - Number(prevValidEntry.time))
: "0";
}

return revlogRowFromEntry(entry, elapsedTime);
});

function formatEaseOrDifficulty(ease: number): string {
if (ease === 0) {
Expand Down Expand Up @@ -145,6 +172,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/each}
</div>
</div>
{#if fsrsEnabled}{/if}
</div>
{/if}

Expand Down
Loading