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>
This commit is contained in:
Jarrett Ye 2024-09-22 17:00:27 +08:00 committed by GitHub
parent 5dfef8aae2
commit 3912db30bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 282 additions and 3 deletions

View file

@ -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

View file

@ -79,6 +79,7 @@ message GraphsResponse {
message Retrievability {
map<uint32, uint32> retrievability = 1;
float average = 2;
float sum = 3;
}
message FutureDue {
map<int32, uint32> 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 {

View file

@ -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()),

View file

@ -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
}
}

View file

@ -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

View file

@ -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,
];
</script>

View file

@ -0,0 +1,41 @@
<!--
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 { GraphsResponse } from "@generated/anki/stats_pb";
import * as tr from "@generated/ftl";
import { renderTrueRetention } from "./true-retention";
import Graph from "./Graph.svelte";
import type { RevlogRange } from "./graph-helpers";
export let revlogRange: RevlogRange;
export let sourceData: GraphsResponse | null = null;
let trueRetentionHtml: string;
$: if (sourceData) {
trueRetentionHtml = renderTrueRetention(sourceData, revlogRange);
}
const title = tr.statisticsTrueRetentionTitle();
const subtitle = tr.statisticsTrueRetentionSubtitle();
</script>
<Graph {title} {subtitle}>
{#if trueRetentionHtml}
<div class="true-retention-table">
{@html trueRetentionHtml}
</div>
{/if}
</Graph>
<style>
.true-retention-table {
overflow-x: auto;
margin-top: 1rem;
display: flex;
justify-content: center;
}
</style>

View file

@ -18,10 +18,15 @@ import type { HistogramData } from "./histogram-graph";
export interface GraphData {
retrievability: Map<number, number>;
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 [

View file

@ -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 `
<tr>
<td class="trl">${name}</td>
<td class="trr">${localizedNumber(data.youngPassed)}</td>
<td class="trr">${localizedNumber(data.youngFailed)}</td>
<td class="trr">${youngRetention}</td>
<td class="trr">${localizedNumber(data.maturePassed)}</td>
<td class="trr">${localizedNumber(data.matureFailed)}</td>
<td class="trr">${matureRetention}</td>
<td class="trr">${localizedNumber(totalPassed)}</td>
<td class="trr">${localizedNumber(totalFailed)}</td>
<td class="trr">${totalRetention}</td>
</tr>`;
}
export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRange): string {
const trueRetention = data.trueRetention!;
const tableContent = `
<style>
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; }
</style>
<table style="border-collapse: collapse;" cellspacing="0" cellpadding="2">
<tr>
<td class="trl" rowspan=3><b>${tr.statisticsTrueRetentionRange()}</b></td>
<td class="trc" colspan=9><b>${tr.statisticsReviewsTitle()}</b></td>
</tr>
<tr>
<td class="trc" colspan=3><b>${tr.statisticsCountsYoungCards()}</b></td>
<td class="trc" colspan=3><b>${tr.statisticsCountsMatureCards()}</b></td>
<td class="trc" colspan=3><b>${tr.statisticsCountsTotalCards()}</b></td>
</tr>
<tr>
<td class="trc">${tr.statisticsTrueRetentionPass()}</td>
<td class="trc">${tr.statisticsTrueRetentionFail()}</td>
<td class="trc">${tr.statisticsTrueRetentionRetention()}</td>
<td class="trc">${tr.statisticsTrueRetentionPass()}</td>
<td class="trc">${tr.statisticsTrueRetentionFail()}</td>
<td class="trc">${tr.statisticsTrueRetentionRetention()}</td>
<td class="trc">${tr.statisticsTrueRetentionPass()}</td>
<td class="trc">${tr.statisticsTrueRetentionFail()}</td>
<td class="trc">${tr.statisticsTrueRetentionRetention()}</td>
</tr>
${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!)
}
</table>`;
return tableContent;
}