Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/true retention stats #3425

Merged
merged 9 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 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.
L-M-Sherlock marked this conversation as resolved.
Show resolved Hide resolved
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
16 changes: 16 additions & 0 deletions proto/anki/stats.proto
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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 +175,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
}
}
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>
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;
}