From 3912db30bb5622a24ff83f4a1f354d694bec251c Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sun, 22 Sep 2024 17:00:27 +0800 Subject: [PATCH] Feat/true retention stats (#3425) * Feat/true retention stats * ./ninja fix:minilints * use translatable strings & update style * remove card couts & add more translatable strings * Update statistics.ftl Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * add Estimated total knowledge (cards) --------- Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> --- ftl/core/statistics.ftl | 13 +++ proto/anki/stats.proto | 17 ++++ rslib/src/stats/graphs/mod.rs | 2 + rslib/src/stats/graphs/retention.rs | 108 +++++++++++++++++++++++ rslib/src/stats/graphs/retrievability.rs | 4 +- ts/routes/graphs/+page.svelte | 2 + ts/routes/graphs/TrueRetention.svelte | 41 +++++++++ ts/routes/graphs/retrievability.ts | 11 ++- ts/routes/graphs/true-retention.ts | 87 ++++++++++++++++++ 9 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 rslib/src/stats/graphs/retention.rs create mode 100644 ts/routes/graphs/TrueRetention.svelte create mode 100644 ts/routes/graphs/true-retention.ts diff --git a/ftl/core/statistics.ftl b/ftl/core/statistics.ftl index 3ab464fa8..f84e5a13f 100644 --- a/ftl/core/statistics.ftl +++ b/ftl/core/statistics.ftl @@ -86,6 +86,18 @@ statistics-counts-learning-cards = Learning statistics-counts-relearning-cards = Relearning statistics-counts-title = Card Counts statistics-counts-separate-suspended-buried-cards = Separate suspended/buried cards +statistics-true-retention-title = True Retention +statistics-true-retention-subtitle = Pass rate of cards with an interval ≥ 1 day. +statistics-true-retention-range = Range +statistics-true-retention-pass = Pass +statistics-true-retention-fail = Fail +statistics-true-retention-retention = Retention +statistics-true-retention-today = Today +statistics-true-retention-yesterday = Yesterday +statistics-true-retention-week = Last week +statistics-true-retention-month = Last month +statistics-true-retention-year = Last year +statistics-true-retention-all-time = All time statistics-range-all-time = all statistics-range-1-year-history = last 12 months statistics-range-all-history = all history @@ -229,6 +241,7 @@ statistics-cards-per-day = statistics-average-ease = Average ease statistics-average-difficulty = Average difficulty statistics-average-retrievability = Average retrievability +statistics-estimated-total-knowledge = Estimated total knowledge statistics-save-pdf = Save PDF statistics-saved = Saved. statistics-stats = stats diff --git a/proto/anki/stats.proto b/proto/anki/stats.proto index 994e17836..1a02c2d5e 100644 --- a/proto/anki/stats.proto +++ b/proto/anki/stats.proto @@ -79,6 +79,7 @@ message GraphsResponse { message Retrievability { map retrievability = 1; float average = 2; + float sum = 3; } message FutureDue { map future_due = 1; @@ -145,6 +146,21 @@ message GraphsResponse { // Buried/suspended cards are counted separately. Counts excluding_inactive = 2; } + message TrueRetentionStats { + message TrueRetention { + uint32 young_passed = 1; + uint32 young_failed = 2; + uint32 mature_passed = 3; + uint32 mature_failed = 4; + } + + TrueRetention today = 1; + TrueRetention yesterday = 2; + TrueRetention week = 3; + TrueRetention month = 4; + TrueRetention year = 5; + TrueRetention all_time = 6; + } Buttons buttons = 1; CardCounts card_counts = 2; @@ -160,6 +176,7 @@ message GraphsResponse { Retrievability retrievability = 12; bool fsrs = 13; Intervals stability = 14; + TrueRetentionStats true_retention = 15; } message GraphPreferences { diff --git a/rslib/src/stats/graphs/mod.rs b/rslib/src/stats/graphs/mod.rs index 0d78d9ba0..863b55c5a 100644 --- a/rslib/src/stats/graphs/mod.rs +++ b/rslib/src/stats/graphs/mod.rs @@ -8,6 +8,7 @@ mod eases; mod future_due; mod hours; mod intervals; +mod retention; mod retrievability; mod reviews; mod today; @@ -65,6 +66,7 @@ impl Collection { let resp = anki_proto::stats::GraphsResponse { added: Some(ctx.added_days()), reviews: Some(ctx.review_counts_and_times()), + true_retention: Some(ctx.calculate_true_retention()), future_due: Some(ctx.future_due()), intervals: Some(ctx.intervals()), stability: Some(ctx.stability()), diff --git a/rslib/src/stats/graphs/retention.rs b/rslib/src/stats/graphs/retention.rs new file mode 100644 index 000000000..34a4f7a67 --- /dev/null +++ b/rslib/src/stats/graphs/retention.rs @@ -0,0 +1,108 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::collections::HashMap; + +use anki_proto::stats::graphs_response::true_retention_stats::TrueRetention; +use anki_proto::stats::graphs_response::TrueRetentionStats; + +use super::GraphsContext; +use super::TimestampSecs; +use crate::revlog::RevlogReviewKind; + +impl GraphsContext { + pub fn calculate_true_retention(&self) -> TrueRetentionStats { + let mut stats = TrueRetentionStats::default(); + + // create periods + let day = 86400; + let periods = vec![ + ( + "today", + self.next_day_start.adding_secs(-day), + self.next_day_start, + ), + ( + "yesterday", + self.next_day_start.adding_secs(-2 * day), + self.next_day_start.adding_secs(-day), + ), + ( + "week", + self.next_day_start.adding_secs(-7 * day), + self.next_day_start, + ), + ( + "month", + self.next_day_start.adding_secs(-30 * day), + self.next_day_start, + ), + ( + "year", + self.next_day_start.adding_secs(-365 * day), + self.next_day_start, + ), + ("all_time", TimestampSecs(0), self.next_day_start), + ]; + + // create period stats + let mut period_stats: HashMap<&str, TrueRetention> = periods + .iter() + .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; + } + } + RevlogReviewKind::Filtered | RevlogReviewKind::Manual => {} + } + } + } + } + + stats.today = Some(period_stats["today"].clone()); + stats.yesterday = Some(period_stats["yesterday"].clone()); + stats.week = Some(period_stats["week"].clone()); + stats.month = Some(period_stats["month"].clone()); + stats.year = Some(period_stats["year"].clone()); + stats.all_time = Some(period_stats["all_time"].clone()); + + stats + } +} diff --git a/rslib/src/stats/graphs/retrievability.rs b/rslib/src/stats/graphs/retrievability.rs index 99b16e309..da2830e8f 100644 --- a/rslib/src/stats/graphs/retrievability.rs +++ b/rslib/src/stats/graphs/retrievability.rs @@ -29,13 +29,13 @@ impl GraphsContext { .retrievability .entry(percent_to_bin(r * 100.0)) .or_insert_with(Default::default) += 1; - retrievability.average += r; + retrievability.sum += r; card_with_retrievability_count += 1; } } if card_with_retrievability_count != 0 { retrievability.average = - retrievability.average * 100.0 / card_with_retrievability_count as f32; + retrievability.sum * 100.0 / card_with_retrievability_count as f32; } retrievability diff --git a/ts/routes/graphs/+page.svelte b/ts/routes/graphs/+page.svelte index 01900217c..dcc9dfb9f 100644 --- a/ts/routes/graphs/+page.svelte +++ b/ts/routes/graphs/+page.svelte @@ -18,6 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ReviewsGraph from "./ReviewsGraph.svelte"; import StabilityGraph from "./StabilityGraph.svelte"; import TodayStats from "./TodayStats.svelte"; + import TrueRetention from "./TrueRetention.svelte"; const graphs = [ TodayStats, @@ -33,6 +34,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html HourGraph, ButtonsGraph, AddedGraph, + TrueRetention, ]; diff --git a/ts/routes/graphs/TrueRetention.svelte b/ts/routes/graphs/TrueRetention.svelte new file mode 100644 index 000000000..11859f077 --- /dev/null +++ b/ts/routes/graphs/TrueRetention.svelte @@ -0,0 +1,41 @@ + + + + + {#if trueRetentionHtml} +
+ {@html trueRetentionHtml} +
+ {/if} +
+ + diff --git a/ts/routes/graphs/retrievability.ts b/ts/routes/graphs/retrievability.ts index 22ac67989..6774bcdf9 100644 --- a/ts/routes/graphs/retrievability.ts +++ b/ts/routes/graphs/retrievability.ts @@ -18,10 +18,15 @@ import type { HistogramData } from "./histogram-graph"; export interface GraphData { retrievability: Map; average: number; + sum: number; } export function gatherData(data: GraphsResponse): GraphData { - return { retrievability: numericMap(data.retrievability!.retrievability), average: data.retrievability!.average }; + return { + retrievability: numericMap(data.retrievability!.retrievability), + average: data.retrievability!.average, + sum: data.retrievability!.sum, + }; } function makeQuery(start: number, end: number): string { @@ -104,6 +109,10 @@ export function prepareData( label: tr.statisticsAverageRetrievability(), value: xTickFormat(data.average), }, + { + label: tr.statisticsEstimatedTotalKnowledge(), + value: tr.statisticsCards({ cards: data.sum }), + }, ]; return [ diff --git a/ts/routes/graphs/true-retention.ts b/ts/routes/graphs/true-retention.ts new file mode 100644 index 000000000..e12ca55ef --- /dev/null +++ b/ts/routes/graphs/true-retention.ts @@ -0,0 +1,87 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import type { GraphsResponse } from "@generated/anki/stats_pb"; +import * as tr from "@generated/ftl"; +import { localizedNumber } from "@tslib/i18n"; +import { RevlogRange } from "./graph-helpers"; + +interface TrueRetentionData { + youngPassed: number; + youngFailed: number; + maturePassed: number; + matureFailed: number; +} + +function calculateRetention(passed: number, failed: number): string { + const total = passed + failed; + if (total === 0) { + return "0%"; + } + return localizedNumber((passed / total) * 100) + "%"; +} + +function createStatsRow(name: string, data: TrueRetentionData): string { + const youngRetention = calculateRetention(data.youngPassed, data.youngFailed); + const matureRetention = calculateRetention(data.maturePassed, data.matureFailed); + const totalPassed = data.youngPassed + data.maturePassed; + const totalFailed = data.youngFailed + data.matureFailed; + const totalRetention = calculateRetention(totalPassed, totalFailed); + + return ` + + ${name} + ${localizedNumber(data.youngPassed)} + ${localizedNumber(data.youngFailed)} + ${youngRetention} + ${localizedNumber(data.maturePassed)} + ${localizedNumber(data.matureFailed)} + ${matureRetention} + ${localizedNumber(totalPassed)} + ${localizedNumber(totalFailed)} + ${totalRetention} + `; +} + +export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRange): string { + const trueRetention = data.trueRetention!; + + const tableContent = ` + + + + + + + + + + + + + + + + + + + + + + + ${createStatsRow(tr.statisticsTrueRetentionToday(), trueRetention.today!)} + ${createStatsRow(tr.statisticsTrueRetentionYesterday(), trueRetention.yesterday!)} + ${createStatsRow(tr.statisticsTrueRetentionWeek(), trueRetention.week!)} + ${createStatsRow(tr.statisticsTrueRetentionMonth(), trueRetention.month!)} + ${ + revlogRange === RevlogRange.Year + ? createStatsRow(tr.statisticsTrueRetentionYear(), trueRetention.year!) + : createStatsRow(tr.statisticsTrueRetentionAllTime(), trueRetention.allTime!) + } +
${tr.statisticsTrueRetentionRange()}${tr.statisticsReviewsTitle()}
${tr.statisticsCountsYoungCards()}${tr.statisticsCountsMatureCards()}${tr.statisticsCountsTotalCards()}
${tr.statisticsTrueRetentionPass()}${tr.statisticsTrueRetentionFail()}${tr.statisticsTrueRetentionRetention()}${tr.statisticsTrueRetentionPass()}${tr.statisticsTrueRetentionFail()}${tr.statisticsTrueRetentionRetention()}${tr.statisticsTrueRetentionPass()}${tr.statisticsTrueRetentionFail()}${tr.statisticsTrueRetentionRetention()}
`; + + return tableContent; +}