Skip to content

Commit

Permalink
Feat/true retention stats (#3425)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* add Estimated total knowledge (cards)

---------

Co-authored-by: user1823 <[email protected]>
  • Loading branch information
L-M-Sherlock and user1823 authored Sep 22, 2024
1 parent 5dfef8a commit 3912db3
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 3 deletions.
13 changes: 13 additions & 0 deletions ftl/core/statistics.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions proto/anki/stats.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -160,6 +176,7 @@ message GraphsResponse {
Retrievability retrievability = 12;
bool fsrs = 13;
Intervals stability = 14;
TrueRetentionStats true_retention = 15;
}

message GraphPreferences {
Expand Down
2 changes: 2 additions & 0 deletions rslib/src/stats/graphs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod eases;
mod future_due;
mod hours;
mod intervals;
mod retention;
mod retrievability;
mod reviews;
mod today;
Expand Down Expand Up @@ -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()),
Expand Down
108 changes: 108 additions & 0 deletions rslib/src/stats/graphs/retention.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions rslib/src/stats/graphs/retrievability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ts/routes/graphs/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +34,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
HourGraph,
ButtonsGraph,
AddedGraph,
TrueRetention,
];
</script>

Expand Down
41 changes: 41 additions & 0 deletions ts/routes/graphs/TrueRetention.svelte
Original file line number Diff line number Diff line change
@@ -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>
11 changes: 10 additions & 1 deletion ts/routes/graphs/retrievability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -104,6 +109,10 @@ export function prepareData(
label: tr.statisticsAverageRetrievability(),
value: xTickFormat(data.average),
},
{
label: tr.statisticsEstimatedTotalKnowledge(),
value: tr.statisticsCards({ cards: data.sum }),
},
];

return [
Expand Down
87 changes: 87 additions & 0 deletions ts/routes/graphs/true-retention.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 3912db3

Please sign in to comment.