diff --git a/rslib/src/stats/graphs/retention.rs b/rslib/src/stats/graphs/retention.rs
index 34a4f7a67..886e42ae5 100644
--- a/rslib/src/stats/graphs/retention.rs
+++ b/rslib/src/stats/graphs/retention.rs
@@ -50,51 +50,32 @@ impl GraphsContext {
.map(|(name, _, _)| (*name, TrueRetention::default()))
.collect();
- for review in &self.revlog {
- for (period_name, start, end) in &periods {
- if review.id.as_secs() >= *start && review.id.as_secs() < *end {
- let period_stat = period_stats.get_mut(period_name).unwrap();
- const MATURE_IVL: i32 = 21; // mature interval is 21 days
-
- match review.review_kind {
- RevlogReviewKind::Learning
- | RevlogReviewKind::Review
- | RevlogReviewKind::Relearning => {
- if review.last_interval < MATURE_IVL
- && review.button_chosen == 1
- && (review.review_kind == RevlogReviewKind::Review
- || review.last_interval <= -86400
- || review.last_interval >= 1)
- {
- period_stat.young_failed += 1;
- } else if review.last_interval < MATURE_IVL
- && review.button_chosen > 1
- && (review.review_kind == RevlogReviewKind::Review
- || review.last_interval <= -86400
- || review.last_interval >= 1)
- {
- period_stat.young_passed += 1;
- } else if review.last_interval >= MATURE_IVL
- && review.button_chosen == 1
- && (review.review_kind == RevlogReviewKind::Review
- || review.last_interval <= -86400
- || review.last_interval >= 1)
- {
- period_stat.mature_failed += 1;
- } else if review.last_interval >= MATURE_IVL
- && review.button_chosen > 1
- && (review.review_kind == RevlogReviewKind::Review
- || review.last_interval <= -86400
- || review.last_interval >= 1)
- {
- period_stat.mature_passed += 1;
- }
+ self.revlog
+ .iter()
+ .filter(|review| {
+ // not manually rescheduled
+ review.button_chosen > 0
+ // not cramming
+ && (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0)
+ // cards with an interval ≥ 1 day
+ && (review.review_kind == RevlogReviewKind::Review
+ || review.last_interval <= -86400
+ || review.last_interval >= 1)
+ })
+ .for_each(|review| {
+ for (period_name, start, end) in &periods {
+ if review.id.as_secs() >= *start && review.id.as_secs() < *end {
+ let period_stat = period_stats.get_mut(period_name).unwrap();
+ const MATURE_IVL: i32 = 21; // mature interval is 21 days
+ match (review.last_interval < MATURE_IVL, review.button_chosen) {
+ (true, 1) => period_stat.young_failed += 1,
+ (true, _) => period_stat.young_passed += 1,
+ (false, 1) => period_stat.mature_failed += 1,
+ (false, _) => period_stat.mature_passed += 1,
}
- RevlogReviewKind::Filtered | RevlogReviewKind::Manual => {}
}
}
- }
- }
+ });
stats.today = Some(period_stats["today"].clone());
stats.yesterday = Some(period_stats["yesterday"].clone());
diff --git a/ts/routes/card-info/ForgettingCurve.svelte b/ts/routes/card-info/ForgettingCurve.svelte
index c01eede93..227d2c108 100644
--- a/ts/routes/card-info/ForgettingCurve.svelte
+++ b/ts/routes/card-info/ForgettingCurve.svelte
@@ -10,32 +10,58 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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 {
+ renderForgettingCurve,
+ TimeRange,
+ calculateMaxDays,
+ filterRevlog,
+ } 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);
+ const filteredRevlog = filterRevlog(revlog);
+ const maxDays = calculateMaxDays(filteredRevlog, TimeRange.AllTime);
+ let defaultTimeRange = TimeRange.Week;
+ if (maxDays > 365) {
+ defaultTimeRange = TimeRange.AllTime;
+ } else if (maxDays > 30) {
+ defaultTimeRange = TimeRange.Year;
+ } else if (maxDays > 7) {
+ defaultTimeRange = TimeRange.Month;
+ }
+ const timeRange = writable(defaultTimeRange);
- $: renderForgettingCurve(revlog, $timeRange, svg as SVGElement, bounds);
+ $: renderForgettingCurve(filteredRevlog, $timeRange, svg as SVGElement, bounds);
-
-
- {#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)}
+ {#if maxDays > 0}
+
+ {/if}
+ {#if maxDays > 7}
+
+ {/if}
+ {#if maxDays > 30}
diff --git a/ts/routes/card-info/Revlog.svelte b/ts/routes/card-info/Revlog.svelte
index 75eb6e5bc..61f4a5978 100644
--- a/ts/routes/card-info/Revlog.svelte
+++ b/ts/routes/card-info/Revlog.svelte
@@ -7,7 +7,7 @@ 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";
+ import { filterRevlogEntryByReviewKind } from "./forgetting-curve";
export let revlog: RevlogEntry[];
export let fsrsEnabled: boolean = false;
@@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let prevValidEntry: RevlogEntry | undefined;
let i = index + 1;
while (i < revlog.length) {
- if (filterRevlogByReviewKind(revlog[i])) {
+ if (filterRevlogEntryByReviewKind(revlog[i])) {
prevValidEntry = revlog[i];
break;
}
@@ -92,7 +92,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
let elapsedTime = "N/A";
- if (filterRevlogByReviewKind(entry)) {
+ if (filterRevlogEntryByReviewKind(entry)) {
elapsedTime = prevValidEntry
? timeSpan(Number(entry.time) - Number(prevValidEntry.time))
: "0";
diff --git a/ts/routes/card-info/forgetting-curve.ts b/ts/routes/card-info/forgetting-curve.ts
index 0db60c78e..7331f7dc5 100644
--- a/ts/routes/card-info/forgetting-curve.ts
+++ b/ts/routes/card-info/forgetting-curve.ts
@@ -13,7 +13,7 @@ import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte";
const FACTOR = 19 / 81;
const DECAY = -0.5;
-const MIN_POINTS = 100;
+const MIN_POINTS = 1000;
function forgettingCurve(stability: number, daysElapsed: number): number {
return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY);
@@ -34,31 +34,44 @@ export enum TimeRange {
AllTime,
}
-function filterDataByTimeRange(data: DataPoint[], range: TimeRange): DataPoint[] {
- const maxDays = {
- [TimeRange.Week]: 7,
- [TimeRange.Month]: 30,
- [TimeRange.Year]: 365,
- [TimeRange.AllTime]: Infinity,
- }[range];
+const MAX_DAYS = {
+ [TimeRange.Week]: 7,
+ [TimeRange.Month]: 30,
+ [TimeRange.Year]: 365,
+ [TimeRange.AllTime]: Infinity,
+};
+function filterDataByTimeRange(data: DataPoint[], maxDays: number): DataPoint[] {
return data.filter((point) => point.daysSinceFirstLearn <= maxDays);
}
-export function filterRevlogByReviewKind(entry: RevlogEntry): boolean {
+export function filterRevlogEntryByReviewKind(entry: RevlogEntry): boolean {
return (
entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL
&& (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)
);
}
-export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
+export function filterRevlog(revlog: RevlogEntry[]): RevlogEntry[] {
+ const result: RevlogEntry[] = [];
+ for (const entry of revlog) {
+ if (entry.reviewKind === RevlogEntry_ReviewKind.MANUAL && entry.ease === 0) {
+ break;
+ }
+ result.push(entry);
+ }
+
+ return result.filter((entry) => filterRevlogEntryByReviewKind(entry));
+}
+
+export function prepareData(revlog: RevlogEntry[], maxDays: number) {
const data: DataPoint[] = [];
let lastReviewTime = 0;
let lastStability = 0;
+ const step = Math.min(maxDays / MIN_POINTS, 1);
+ let daysSinceFirstLearn = 0;
revlog
- .filter((entry) => filterRevlogByReviewKind(entry))
.toReversed()
.forEach((entry, index) => {
const reviewTime = Number(entry.time);
@@ -76,9 +89,9 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
}
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;
+ let elapsedDays = 0;
+ while (elapsedDays < totalDaysElapsed - step) {
+ elapsedDays += step;
const retrievability = forgettingCurve(lastStability, elapsedDays);
data.push({
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
@@ -88,10 +101,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
stability: lastStability,
});
}
-
+ daysSinceFirstLearn += totalDaysElapsed;
data.push({
date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),
- daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn,
+ daysSinceFirstLearn: daysSinceFirstLearn,
retrievability: 100,
elapsedDaysSinceLastReview: 0,
stability: lastStability,
@@ -106,10 +119,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
}
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 totalDaysSinceLastReview = (now - lastReviewTime) / (24 * 60 * 60);
+ let elapsedDays = 0;
+ while (elapsedDays < totalDaysSinceLastReview - step) {
+ elapsedDays += step;
const retrievability = forgettingCurve(lastStability, elapsedDays);
data.push({
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
@@ -119,19 +132,44 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
stability: lastStability,
});
}
- const filteredData = filterDataByTimeRange(data, timeRange);
+ daysSinceFirstLearn += totalDaysSinceLastReview;
+ const retrievability = forgettingCurve(lastStability, totalDaysSinceLastReview);
+ data.push({
+ date: new Date(now * 1000),
+ daysSinceFirstLearn: daysSinceFirstLearn,
+ elapsedDaysSinceLastReview: totalDaysSinceLastReview,
+ retrievability: retrievability * 100,
+ stability: lastStability,
+ });
+
+ const filteredData = filterDataByTimeRange(data, maxDays);
return filteredData;
}
+export function calculateMaxDays(filteredRevlog: RevlogEntry[], timeRange: TimeRange): number {
+ if (filteredRevlog.length === 0) {
+ return 0;
+ }
+ const daysSinceFirstLearn = (Date.now() / 1000 - Number(filteredRevlog[filteredRevlog.length - 1].time))
+ / (24 * 60 * 60);
+ return Math.min(daysSinceFirstLearn, MAX_DAYS[timeRange]);
+}
+
export function renderForgettingCurve(
- revlog: RevlogEntry[],
+ filteredRevlog: 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 (filteredRevlog.length === 0) {
+ setDataAvailable(svg, false);
+ return;
+ }
+ const maxDays = calculateMaxDays(filteredRevlog, timeRange);
+
+ const data = prepareData(filteredRevlog, maxDays);
if (data.length === 0) {
setDataAvailable(svg, false);
@@ -186,7 +224,9 @@ export function renderForgettingCurve(
.style("opacity", 0);
function tooltipText(d: DataPoint): string {
- return `Date: ${d.date.toLocaleString()}
+ return `${maxDays >= 365 ? "Date" : "Date Time"}: ${
+ maxDays >= 365 ? d.date.toLocaleDateString() : d.date.toLocaleString()
+ }
${tr.cardStatsReviewLogElapsedTime()}: ${
timeSpan(d.elapsedDaysSinceLastReview * 86400)
}
${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%
${tr.cardStatsFsrsStability()}: ${
diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte
index b9056b027..2cef1537f 100644
--- a/ts/routes/deck-options/FsrsOptions.svelte
+++ b/ts/routes/deck-options/FsrsOptions.svelte
@@ -324,7 +324,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if (resp) {
const dailyTimeCost = movingAverage(
resp.dailyTimeCost,
- Math.round(simulateFsrsRequest.daysToSimulate / 50),
+ Math.ceil(simulateFsrsRequest.daysToSimulate / 50),
);
points = points.concat(
dailyTimeCost.map((v, i) => ({
diff --git a/ts/routes/graphs/TrueRetention.svelte b/ts/routes/graphs/TrueRetention.svelte
index 11859f077..37af98116 100644
--- a/ts/routes/graphs/TrueRetention.svelte
+++ b/ts/routes/graphs/TrueRetention.svelte
@@ -36,6 +36,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
overflow-x: auto;
margin-top: 1rem;
display: flex;
- justify-content: center;
}
diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts
index e47ea8a27..aaee87a90 100644
--- a/ts/routes/graphs/simulator.ts
+++ b/ts/routes/graphs/simulator.ts
@@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-import { localizedNumber } from "@tslib/i18n";
+import { localizedDate } from "@tslib/i18n";
import {
axisBottom,
axisLeft,
@@ -14,9 +14,9 @@ import {
scaleTime,
schemeCategory10,
select,
- timeFormat,
} from "d3";
+import { timeSpan } from "@tslib/time";
import type { GraphBounds, TableDatum } from "./graph-helpers";
import { setDataAvailable } from "./graph-helpers";
import { hideTooltip, showTooltip } from "./tooltip-utils.svelte";
@@ -48,7 +48,6 @@ export function renderSimulationChart(
const convertedData = data.map(d => ({
...d,
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
- yMinutes: d.y / 60,
}));
const xMin = today;
const xMax = max(convertedData, d => d.date);
@@ -56,29 +55,17 @@ export function renderSimulationChart(
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
- const formatDate = timeFormat("%Y-%m-%d");
svg.select(".x-ticks")
- .call((selection) =>
- selection.transition(trans).call(
- axisBottom(x)
- .ticks(7)
- .tickFormat((d: any) => formatDate(d))
- .tickSizeOuter(0),
- )
- )
+ .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yTickFormat = (n: number): string => {
- if (Math.round(n) != n) {
- return "";
- } else {
- return localizedNumber(n);
- }
+ return timeSpan(n, true);
};
- const yMax = max(convertedData, d => d.yMinutes)!;
+ const yMax = max(convertedData, d => d.y)!;
const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
@@ -103,10 +90,10 @@ export function renderSimulationChart(
.attr("dy", "1em")
.attr("fill", "currentColor")
.style("text-anchor", "middle")
- .text("Review Time per day (minutes)");
+ .text("Review Time per day");
// x lines
- const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]);
+ const points = convertedData.map((d) => [x(d.date), y(d.y), d.label]);
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
const color = schemeCategory10;
@@ -120,7 +107,6 @@ export function renderSimulationChart(
.selectAll("path")
.data(Array.from(groups.entries()))
.join("path")
- .style("mix-blend-mode", "multiply")
.attr("stroke", (d, i) => color[i % color.length])
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
.attr("data-group", d => d[0]);
@@ -148,7 +134,10 @@ export function renderSimulationChart(
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
.attr("fill", "transparent")
.on("mousemove", mousemove)
- .on("mouseout", hideTooltip);
+ .on("mouseout", () => {
+ focusLine.style("opacity", 0);
+ hideTooltip();
+ });
function mousemove(event: MouseEvent, d: any): void {
pointer(event, document.body);
@@ -168,9 +157,10 @@ export function renderSimulationChart(
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
- let tooltipContent = `Date: ${timeFormat("%Y-%m-%d")(date)}
`;
+ const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
+ let tooltipContent = `Date: ${localizedDate(date)}
In ${days} Days
`;
for (const [key, value] of Object.entries(groupData)) {
- tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes
`;
+ tooltipContent += `#${key}: ${timeSpan(value)}
`;
}
showTooltip(tooltipContent, event.pageX, event.pageY);
@@ -189,16 +179,17 @@ export function renderSimulationChart(
.on("click", (event, d) => toggleGroup(event, d));
legend.append("rect")
- .attr("x", bounds.width - bounds.marginRight + 10)
- .attr("width", 19)
- .attr("height", 19)
+ .attr("x", bounds.width - bounds.marginRight + 36)
+ .attr("width", 12)
+ .attr("height", 12)
.attr("fill", (d, i) => color[i % color.length]);
legend.append("text")
- .attr("x", bounds.width - bounds.marginRight + 34)
- .attr("y", 9.5)
- .attr("dy", "0.32em")
- .text(d => `Simulation ${d}`);
+ .attr("x", bounds.width - bounds.marginRight + 52)
+ .attr("y", 7)
+ .attr("dy", "0.3em")
+ .attr("fill", "currentColor")
+ .text(d => `#${d}`);
const toggleGroup = (event: MouseEvent, d: number) => {
const group = d;
diff --git a/ts/routes/graphs/true-retention.ts b/ts/routes/graphs/true-retention.ts
index e12ca55ef..8aa5079a8 100644
--- a/ts/routes/graphs/true-retention.ts
+++ b/ts/routes/graphs/true-retention.ts
@@ -17,7 +17,7 @@ function calculateRetention(passed: number, failed: number): string {
if (total === 0) {
return "0%";
}
- return localizedNumber((passed / total) * 100) + "%";
+ return localizedNumber((passed / total) * 100, 1) + "%";
}
function createStatsRow(name: string, data: TrueRetentionData): string {
@@ -50,8 +50,9 @@ export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRan
td.trl { border: 1px solid; text-align: left; padding: 5px; }
td.trr { border: 1px solid; text-align: right; padding: 5px; }
td.trc { border: 1px solid; text-align: center; padding: 5px; }
+ table { width: 100%; margin: 0px 25px 20px 25px; }
-
+
${tr.statisticsTrueRetentionRange()} |
${tr.statisticsReviewsTitle()} |