mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
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:
parent
5dfef8aae2
commit
3912db30bb
9 changed files with 282 additions and 3 deletions
|
@ -86,6 +86,18 @@ statistics-counts-learning-cards = Learning
|
||||||
statistics-counts-relearning-cards = Relearning
|
statistics-counts-relearning-cards = Relearning
|
||||||
statistics-counts-title = Card Counts
|
statistics-counts-title = Card Counts
|
||||||
statistics-counts-separate-suspended-buried-cards = Separate suspended/buried cards
|
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-all-time = all
|
||||||
statistics-range-1-year-history = last 12 months
|
statistics-range-1-year-history = last 12 months
|
||||||
statistics-range-all-history = all history
|
statistics-range-all-history = all history
|
||||||
|
@ -229,6 +241,7 @@ statistics-cards-per-day =
|
||||||
statistics-average-ease = Average ease
|
statistics-average-ease = Average ease
|
||||||
statistics-average-difficulty = Average difficulty
|
statistics-average-difficulty = Average difficulty
|
||||||
statistics-average-retrievability = Average retrievability
|
statistics-average-retrievability = Average retrievability
|
||||||
|
statistics-estimated-total-knowledge = Estimated total knowledge
|
||||||
statistics-save-pdf = Save PDF
|
statistics-save-pdf = Save PDF
|
||||||
statistics-saved = Saved.
|
statistics-saved = Saved.
|
||||||
statistics-stats = stats
|
statistics-stats = stats
|
||||||
|
|
|
@ -79,6 +79,7 @@ message GraphsResponse {
|
||||||
message Retrievability {
|
message Retrievability {
|
||||||
map<uint32, uint32> retrievability = 1;
|
map<uint32, uint32> retrievability = 1;
|
||||||
float average = 2;
|
float average = 2;
|
||||||
|
float sum = 3;
|
||||||
}
|
}
|
||||||
message FutureDue {
|
message FutureDue {
|
||||||
map<int32, uint32> future_due = 1;
|
map<int32, uint32> future_due = 1;
|
||||||
|
@ -145,6 +146,21 @@ message GraphsResponse {
|
||||||
// Buried/suspended cards are counted separately.
|
// Buried/suspended cards are counted separately.
|
||||||
Counts excluding_inactive = 2;
|
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;
|
Buttons buttons = 1;
|
||||||
CardCounts card_counts = 2;
|
CardCounts card_counts = 2;
|
||||||
|
@ -160,6 +176,7 @@ message GraphsResponse {
|
||||||
Retrievability retrievability = 12;
|
Retrievability retrievability = 12;
|
||||||
bool fsrs = 13;
|
bool fsrs = 13;
|
||||||
Intervals stability = 14;
|
Intervals stability = 14;
|
||||||
|
TrueRetentionStats true_retention = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GraphPreferences {
|
message GraphPreferences {
|
||||||
|
|
|
@ -8,6 +8,7 @@ mod eases;
|
||||||
mod future_due;
|
mod future_due;
|
||||||
mod hours;
|
mod hours;
|
||||||
mod intervals;
|
mod intervals;
|
||||||
|
mod retention;
|
||||||
mod retrievability;
|
mod retrievability;
|
||||||
mod reviews;
|
mod reviews;
|
||||||
mod today;
|
mod today;
|
||||||
|
@ -65,6 +66,7 @@ impl Collection {
|
||||||
let resp = anki_proto::stats::GraphsResponse {
|
let resp = anki_proto::stats::GraphsResponse {
|
||||||
added: Some(ctx.added_days()),
|
added: Some(ctx.added_days()),
|
||||||
reviews: Some(ctx.review_counts_and_times()),
|
reviews: Some(ctx.review_counts_and_times()),
|
||||||
|
true_retention: Some(ctx.calculate_true_retention()),
|
||||||
future_due: Some(ctx.future_due()),
|
future_due: Some(ctx.future_due()),
|
||||||
intervals: Some(ctx.intervals()),
|
intervals: Some(ctx.intervals()),
|
||||||
stability: Some(ctx.stability()),
|
stability: Some(ctx.stability()),
|
||||||
|
|
108
rslib/src/stats/graphs/retention.rs
Normal file
108
rslib/src/stats/graphs/retention.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,13 +29,13 @@ impl GraphsContext {
|
||||||
.retrievability
|
.retrievability
|
||||||
.entry(percent_to_bin(r * 100.0))
|
.entry(percent_to_bin(r * 100.0))
|
||||||
.or_insert_with(Default::default) += 1;
|
.or_insert_with(Default::default) += 1;
|
||||||
retrievability.average += r;
|
retrievability.sum += r;
|
||||||
card_with_retrievability_count += 1;
|
card_with_retrievability_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if card_with_retrievability_count != 0 {
|
if card_with_retrievability_count != 0 {
|
||||||
retrievability.average =
|
retrievability.average =
|
||||||
retrievability.average * 100.0 / card_with_retrievability_count as f32;
|
retrievability.sum * 100.0 / card_with_retrievability_count as f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
retrievability
|
retrievability
|
||||||
|
|
|
@ -18,6 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import ReviewsGraph from "./ReviewsGraph.svelte";
|
import ReviewsGraph from "./ReviewsGraph.svelte";
|
||||||
import StabilityGraph from "./StabilityGraph.svelte";
|
import StabilityGraph from "./StabilityGraph.svelte";
|
||||||
import TodayStats from "./TodayStats.svelte";
|
import TodayStats from "./TodayStats.svelte";
|
||||||
|
import TrueRetention from "./TrueRetention.svelte";
|
||||||
|
|
||||||
const graphs = [
|
const graphs = [
|
||||||
TodayStats,
|
TodayStats,
|
||||||
|
@ -33,6 +34,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
HourGraph,
|
HourGraph,
|
||||||
ButtonsGraph,
|
ButtonsGraph,
|
||||||
AddedGraph,
|
AddedGraph,
|
||||||
|
TrueRetention,
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
41
ts/routes/graphs/TrueRetention.svelte
Normal file
41
ts/routes/graphs/TrueRetention.svelte
Normal 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>
|
|
@ -18,10 +18,15 @@ import type { HistogramData } from "./histogram-graph";
|
||||||
export interface GraphData {
|
export interface GraphData {
|
||||||
retrievability: Map<number, number>;
|
retrievability: Map<number, number>;
|
||||||
average: number;
|
average: number;
|
||||||
|
sum: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gatherData(data: GraphsResponse): GraphData {
|
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 {
|
function makeQuery(start: number, end: number): string {
|
||||||
|
@ -104,6 +109,10 @@ export function prepareData(
|
||||||
label: tr.statisticsAverageRetrievability(),
|
label: tr.statisticsAverageRetrievability(),
|
||||||
value: xTickFormat(data.average),
|
value: xTickFormat(data.average),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: tr.statisticsEstimatedTotalKnowledge(),
|
||||||
|
value: tr.statisticsCards({ cards: data.sum }),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
87
ts/routes/graphs/true-retention.ts
Normal file
87
ts/routes/graphs/true-retention.ts
Normal 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;
|
||||||
|
}
|
Loading…
Reference in a new issue