mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02: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-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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()),
|
||||
|
|
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
|
||||
.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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
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 {
|
||||
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 [
|
||||
|
|
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