mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Feat/forgetting curve in card info (#3437)
* add elapsed time into revlog-table * add stability into revlog-table * add Graph of forgetting curve * fix eslint * add radio buttons of timeRange * add revlog filter && return [] for new card * format * translatable string & disable if using SM-2 * elapsedTime should skip manual or filtered review * add HoverColumns * fix eslint * add stability to tooltip & use timeSpan * reuse translatable strings * distinguish daysSinceFirstLearn and elapsedDaysSinceLastReview * Date x-axis & toLocaleString * Temporarily hide elapsed/stability columns (dae) https://github.com/ankitects/anki/pull/3437#issuecomment-2378851900
This commit is contained in:
parent
f3b0afcc62
commit
79592730ee
7 changed files with 394 additions and 4 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
@ -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}
|
||||
|
|
85
ts/routes/card-info/ForgettingCurve.svelte
Normal file
85
ts/routes/card-info/ForgettingCurve.svelte
Normal file
|
@ -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>
|
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
|
||||
|
|
220
ts/routes/card-info/forgetting-curve.ts
Normal file
220
ts/routes/card-info/forgetting-curve.ts
Normal file
|
@ -0,0 +1,220 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import {
|
||||
type CardStatsResponse_StatsRevlogEntry as RevlogEntry,
|
||||
RevlogEntry_ReviewKind,
|
||||
} from "@generated/anki/stats_pb";
|
||||
import * as tr from "@generated/ftl";
|
||||
import { timeSpan } from "@tslib/time";
|
||||
import { axisBottom, axisLeft, line, max, min, pointer, scaleLinear, scaleTime, select } from "d3";
|
||||
import { type GraphBounds, setDataAvailable } from "../graphs/graph-helpers";
|
||||
import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte";
|
||||
|
||||
const FACTOR = 19 / 81;
|
||||
const DECAY = -0.5;
|
||||
const MIN_POINTS = 100;
|
||||
|
||||
function forgettingCurve(stability: number, daysElapsed: number): number {
|
||||
return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY);
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
date: Date;
|
||||
daysSinceFirstLearn: number;
|
||||
elapsedDaysSinceLastReview: number;
|
||||
retrievability: number;
|
||||
stability: number;
|
||||
}
|
||||
|
||||
export enum TimeRange {
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
AllTime,
|
||||
}
|
||||
|
||||
function filterDataByTimeRange(data: DataPoint[], range: TimeRange): DataPoint[] {
|
||||
const maxDays = {
|
||||
[TimeRange.Week]: 7,
|
||||
[TimeRange.Month]: 30,
|
||||
[TimeRange.Year]: 365,
|
||||
[TimeRange.AllTime]: Infinity,
|
||||
}[range];
|
||||
|
||||
return data.filter((point) => point.daysSinceFirstLearn <= maxDays);
|
||||
}
|
||||
|
||||
export function filterRevlogByReviewKind(entry: RevlogEntry): boolean {
|
||||
return (
|
||||
entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL
|
||||
&& (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
||||
const data: DataPoint[] = [];
|
||||
let lastReviewTime = 0;
|
||||
let lastStability = 0;
|
||||
|
||||
revlog
|
||||
.filter((entry) => filterRevlogByReviewKind(entry))
|
||||
.toReversed()
|
||||
.forEach((entry, index) => {
|
||||
const reviewTime = Number(entry.time);
|
||||
if (index === 0) {
|
||||
lastReviewTime = reviewTime;
|
||||
lastStability = entry.memoryState?.stability || 0;
|
||||
data.push({
|
||||
date: new Date(reviewTime * 1000),
|
||||
daysSinceFirstLearn: 0,
|
||||
elapsedDaysSinceLastReview: 0,
|
||||
retrievability: 100,
|
||||
stability: lastStability,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDaysElapsed = (reviewTime - lastReviewTime) / (24 * 60 * 60);
|
||||
const step = Math.min(1, totalDaysElapsed / MIN_POINTS);
|
||||
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysElapsed); i++) {
|
||||
const elapsedDays = (i + 1) * step;
|
||||
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
||||
data.push({
|
||||
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
||||
daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step,
|
||||
elapsedDaysSinceLastReview: elapsedDays,
|
||||
retrievability: retrievability * 100,
|
||||
stability: lastStability,
|
||||
});
|
||||
}
|
||||
|
||||
data.push({
|
||||
date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),
|
||||
daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn,
|
||||
retrievability: 100,
|
||||
elapsedDaysSinceLastReview: 0,
|
||||
stability: lastStability,
|
||||
});
|
||||
|
||||
lastReviewTime = reviewTime;
|
||||
lastStability = entry.memoryState?.stability || 0;
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
const totalDaysSinceLastReview = Math.floor((now - lastReviewTime) / (24 * 60 * 60));
|
||||
const step = Math.min(1, totalDaysSinceLastReview / MIN_POINTS);
|
||||
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysSinceLastReview); i++) {
|
||||
const elapsedDays = (i + 1) * step;
|
||||
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
||||
data.push({
|
||||
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
||||
daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step,
|
||||
elapsedDaysSinceLastReview: elapsedDays,
|
||||
retrievability: retrievability * 100,
|
||||
stability: lastStability,
|
||||
});
|
||||
}
|
||||
const filteredData = filterDataByTimeRange(data, timeRange);
|
||||
return filteredData;
|
||||
}
|
||||
|
||||
export function renderForgettingCurve(
|
||||
revlog: RevlogEntry[],
|
||||
timeRange: TimeRange,
|
||||
svgElem: SVGElement,
|
||||
bounds: GraphBounds,
|
||||
) {
|
||||
const data = prepareData(revlog, timeRange);
|
||||
const svg = select(svgElem);
|
||||
const trans = svg.transition().duration(600) as any;
|
||||
|
||||
if (data.length === 0) {
|
||||
setDataAvailable(svg, false);
|
||||
return;
|
||||
} else {
|
||||
setDataAvailable(svg, true);
|
||||
}
|
||||
|
||||
svg.select(".forgetting-curve-line").remove();
|
||||
svg.select(".hover-columns").remove();
|
||||
|
||||
const xMin = min(data, d => d.date);
|
||||
const xMax = max(data, d => d.date);
|
||||
const x = scaleTime()
|
||||
.domain([xMin!, xMax!])
|
||||
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
||||
const yMin = Math.max(
|
||||
0,
|
||||
100 - 1.2 * (100 - Math.min(...data.map((d) => d.retrievability))),
|
||||
);
|
||||
const y = scaleLinear()
|
||||
.domain([yMin, 100])
|
||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop]);
|
||||
|
||||
svg.select<SVGGElement>(".x-ticks")
|
||||
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(5).tickSizeOuter(0)))
|
||||
.attr("direction", "ltr");
|
||||
|
||||
svg.select<SVGGElement>(".y-ticks")
|
||||
.attr("transform", `translate(${bounds.marginLeft},0)`)
|
||||
.call((selection) => selection.transition(trans).call(axisLeft(y).tickSizeOuter(0)))
|
||||
.attr("direction", "ltr");
|
||||
|
||||
const lineGenerator = line<DataPoint>()
|
||||
.x((d) => x(d.date))
|
||||
.y((d) => y(d.retrievability));
|
||||
|
||||
svg.append("path")
|
||||
.datum(data)
|
||||
.attr("class", "forgetting-curve-line")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "steelblue")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
const focusLine = svg.append("line")
|
||||
.attr("class", "focus-line")
|
||||
.attr("y1", bounds.marginTop)
|
||||
.attr("y2", bounds.height - bounds.marginBottom)
|
||||
.attr("stroke", "black")
|
||||
.attr("stroke-width", 1)
|
||||
.style("opacity", 0);
|
||||
|
||||
function tooltipText(d: DataPoint): string {
|
||||
return `Date: ${d.date.toLocaleString()}<br>
|
||||
${tr.cardStatsReviewLogElapsedTime()}: ${
|
||||
timeSpan(d.elapsedDaysSinceLastReview * 86400)
|
||||
}<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${tr.cardStatsFsrsStability()}: ${
|
||||
timeSpan(d.stability * 86400)
|
||||
}`;
|
||||
}
|
||||
|
||||
// hover/tooltip
|
||||
svg.append("g")
|
||||
.attr("class", "hover-columns")
|
||||
.selectAll("rect")
|
||||
.data(data)
|
||||
.join("rect")
|
||||
.attr("x", d => x(d.date) - 1)
|
||||
.attr("y", bounds.marginTop)
|
||||
.attr("width", 2)
|
||||
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
|
||||
.attr("fill", "transparent")
|
||||
.on("mousemove", (event: MouseEvent, d: DataPoint) => {
|
||||
const [x1, y1] = pointer(event, document.body);
|
||||
focusLine.attr("x1", x(d.date) - 1).attr("x2", x(d.date) + 1).style(
|
||||
"opacity",
|
||||
1,
|
||||
);
|
||||
showTooltip(tooltipText(d), x1, y1);
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
focusLine.style("opacity", 0);
|
||||
hideTooltip();
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue