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()}