diff --git a/ftl/core/statistics.ftl b/ftl/core/statistics.ftl index 006db0cf5..0a2b1d5e1 100644 --- a/ftl/core/statistics.ftl +++ b/ftl/core/statistics.ftl @@ -96,7 +96,9 @@ statistics-true-retention-subtitle = Pass rate of cards with an interval ≥ 1 d statistics-true-retention-range = Range statistics-true-retention-pass = Pass statistics-true-retention-fail = Fail +statistics-true-retention-count = Count statistics-true-retention-retention = Retention +statistics-true-retention-all = All statistics-true-retention-today = Today statistics-true-retention-yesterday = Yesterday statistics-true-retention-week = Last week diff --git a/ts/routes/graphs/TrueRetention.svelte b/ts/routes/graphs/TrueRetention.svelte index b9a3f0efc..e8934ade0 100644 --- a/ts/routes/graphs/TrueRetention.svelte +++ b/ts/routes/graphs/TrueRetention.svelte @@ -6,34 +6,87 @@ 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 { renderTrueRetention } from "./true-retention"; + import { type RevlogRange } from "./graph-helpers"; + import { DisplayMode, type PeriodTrueRetentionData, Scope } from "./true-retention"; + import Graph from "./Graph.svelte"; - import type { RevlogRange } from "./graph-helpers"; + import InputBox from "./InputBox.svelte"; + import TrueRetentionCombined from "./TrueRetentionCombined.svelte"; + import TrueRetentionSingle from "./TrueRetentionSingle.svelte"; + import { assertUnreachable } from "@tslib/typing"; - export let revlogRange: RevlogRange; - export let sourceData: GraphsResponse | null = null; - - let trueRetentionHtml: string; - - $: if (sourceData) { - trueRetentionHtml = renderTrueRetention(sourceData, revlogRange); + interface Props { + revlogRange: RevlogRange; + sourceData: GraphsResponse | null; } + const { revlogRange, sourceData = null }: Props = $props(); + + const retentionData: PeriodTrueRetentionData | null = $derived.by(() => { + if (sourceData === null) { + return null; + } else { + // Assert that all the True Retention data will be defined + return sourceData.trueRetention as PeriodTrueRetentionData; + } + }); + + let mode: DisplayMode = $state(DisplayMode.Summary); + const title = tr.statisticsTrueRetentionTitle(); const subtitle = tr.statisticsTrueRetentionSubtitle(); - {#if trueRetentionHtml} -
- {@html trueRetentionHtml} -
- {/if} + + + + + + + + +
+ {#if retentionData === null} +
{tr.statisticsNoData()}
+ {:else if mode === DisplayMode.Young} + + {:else if mode === DisplayMode.Mature} + + {:else if mode === DisplayMode.All} + + {:else if mode === DisplayMode.Summary} + + {:else} + {assertUnreachable(mode)} + {/if} +
diff --git a/ts/routes/graphs/TrueRetentionCombined.svelte b/ts/routes/graphs/TrueRetentionCombined.svelte new file mode 100644 index 000000000..48d3ac437 --- /dev/null +++ b/ts/routes/graphs/TrueRetentionCombined.svelte @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + {#each rowData as row} + {@const totalPassed = row.data.youngPassed + row.data.maturePassed} + {@const totalFailed = row.data.youngFailed + row.data.matureFailed} + + + + + + + + + + + {/each} + +
+ {tr.statisticsCountsYoungCards()} + + {tr.statisticsCountsMatureCards()} + + {tr.statisticsCountsTotalCards()} + + {tr.statisticsTrueRetentionCount()} +
{row.title} + {calculateRetentionPercentageString( + row.data.youngPassed, + row.data.youngFailed, + )} + + {calculateRetentionPercentageString( + row.data.maturePassed, + row.data.matureFailed, + )} + + {calculateRetentionPercentageString(totalPassed, totalFailed)} + {localizedNumber(totalPassed + totalFailed)}
+ + diff --git a/ts/routes/graphs/TrueRetentionSingle.svelte b/ts/routes/graphs/TrueRetentionSingle.svelte new file mode 100644 index 000000000..9199717f8 --- /dev/null +++ b/ts/routes/graphs/TrueRetentionSingle.svelte @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + {#each rowData as row} + {@const passed = getPassed(row.data, scope)} + {@const failed = getFailed(row.data, scope)} + + + + + + + + + {/each} + +
+ {tr.statisticsTrueRetentionPass()} + + {tr.statisticsTrueRetentionFail()} + + {tr.statisticsTrueRetentionRetention()} +
{row.title}{localizedNumber(passed)}{localizedNumber(failed)} + {calculateRetentionPercentageString(passed, failed)} +
+ + diff --git a/ts/routes/graphs/_true-retention-base.scss b/ts/routes/graphs/_true-retention-base.scss new file mode 100644 index 000000000..a674de362 --- /dev/null +++ b/ts/routes/graphs/_true-retention-base.scss @@ -0,0 +1,18 @@ +table { + border-collapse: collapse; +} + +tr { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +td, +th { + padding-left: 0.5em; + padding-right: 0.5em; +} + +.row-header { + color: var(--fg); +} diff --git a/ts/routes/graphs/true-retention.ts b/ts/routes/graphs/true-retention.ts index 964a32efc..8aacc376c 100644 --- a/ts/routes/graphs/true-retention.ts +++ b/ts/routes/graphs/true-retention.ts @@ -1,103 +1,117 @@ // 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 { assertUnreachable } from "@tslib/typing"; import { RevlogRange } from "./graph-helpers"; -interface TrueRetentionData { +export 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, 1) + "%"; +export interface PeriodTrueRetentionData { + today: TrueRetentionData; + yesterday: TrueRetentionData; + week: TrueRetentionData; + month: TrueRetentionData; + year: TrueRetentionData; + allTime: TrueRetentionData; } -enum Scope { +export enum DisplayMode { Young, Mature, - Total, + All, + Summary, } -function createStatsRow(period: string, data: TrueRetentionData, scope: Scope): string { - let pass: number, fail: number, retention: string; +export enum Scope { + Young, + Mature, + All, +} + +export function getPassed(data: TrueRetentionData, scope: Scope): number { switch (scope) { case Scope.Young: - pass = data.youngPassed; - fail = data.youngFailed; - retention = calculateRetention(data.youngPassed, data.youngFailed); - break; + return data.youngPassed; case Scope.Mature: - pass = data.maturePassed; - fail = data.matureFailed; - retention = calculateRetention(data.maturePassed, data.matureFailed); - break; - case Scope.Total: - pass = data.youngPassed + data.maturePassed; - fail = data.youngFailed + data.matureFailed; - retention = calculateRetention(pass, fail); - break; + return data.maturePassed; + case Scope.All: + return data.youngPassed + data.maturePassed; + default: + assertUnreachable(scope); } - - return ` - - ${period} - ${localizedNumber(pass)} - ${localizedNumber(fail)} - ${retention} - `; } -export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRange): string { - const trueRetention = data.trueRetention!; - let output = ""; - for (const scope of Object.values(Scope)) { - if (typeof scope === "string") { continue; } - const tableContent = ` - - - - - - - - - - ${createStatsRow(tr.statisticsTrueRetentionToday(), trueRetention.today!, scope)} - ${createStatsRow(tr.statisticsTrueRetentionYesterday(), trueRetention.yesterday!, scope)} - ${createStatsRow(tr.statisticsTrueRetentionWeek(), trueRetention.week!, scope)} - ${createStatsRow(tr.statisticsTrueRetentionMonth(), trueRetention.month!, scope)} - ${ - revlogRange === RevlogRange.Year - ? createStatsRow(tr.statisticsTrueRetentionYear(), trueRetention.year!, scope) - : createStatsRow(tr.statisticsTrueRetentionAllTime(), trueRetention.allTime!, scope) - } -
${scopeRange(scope)}${tr.statisticsTrueRetentionPass()}${tr.statisticsTrueRetentionFail()}${tr.statisticsTrueRetentionRetention()}
`; - output += tableContent; - } - - return output; -} -function scopeRange(scope: Scope) { +export function getFailed(data: TrueRetentionData, scope: Scope): number { switch (scope) { case Scope.Young: - return tr.statisticsCountsYoungCards(); + return data.youngFailed; case Scope.Mature: - return tr.statisticsCountsMatureCards(); - case Scope.Total: - return tr.statisticsTotal(); + return data.matureFailed; + case Scope.All: + return data.youngFailed + data.matureFailed; + default: + assertUnreachable(scope); } } + +export interface RowData { + title: string; + data: TrueRetentionData; +} + +export function getRowData( + allData: PeriodTrueRetentionData, + revlogRange: RevlogRange, +): RowData[] { + const rowData: RowData[] = [ + { + title: tr.statisticsTrueRetentionToday(), + data: allData.today, + }, + { + title: tr.statisticsTrueRetentionYesterday(), + data: allData.yesterday, + }, + { + title: tr.statisticsTrueRetentionWeek(), + data: allData.week, + }, + { + title: tr.statisticsTrueRetentionMonth(), + data: allData.month, + }, + { + title: tr.statisticsTrueRetentionYear(), + data: allData.year, + }, + ]; + + if (revlogRange === RevlogRange.All) { + rowData.push({ + title: tr.statisticsTrueRetentionAllTime(), + data: allData.allTime, + }); + } + + return rowData; +} + +export function calculateRetentionPercentageString( + passed: number, + failed: number, +): string { + let percentage = 0; + const total = passed + failed; + + if (total !== 0) { + percentage = (passed / total) * 100; + } + + return localizedNumber(percentage, 1) + "%"; +}