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}
+
+
+
+
+
+ {calculateRetentionPercentageString(
+ row.data.youngPassed,
+ row.data.youngFailed,
+ )}
+ |
+
+ {calculateRetentionPercentageString(
+ row.data.maturePassed,
+ row.data.matureFailed,
+ )}
+ |
+
+ {calculateRetentionPercentageString(totalPassed, totalFailed)}
+ |
+
+ {localizedNumber(totalPassed + totalFailed)} |
+
+ {/each}
+
+
+
+
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)}
+
+
+
+
+ {localizedNumber(passed)} |
+ {localizedNumber(failed)} |
+
+ {calculateRetentionPercentageString(passed, failed)}
+ |
+
+ {/each}
+
+
+
+
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 = `
-
-
-
-
- ${scopeRange(scope)} |
- ${tr.statisticsTrueRetentionPass()} |
- ${tr.statisticsTrueRetentionFail()} |
- ${tr.statisticsTrueRetentionRetention()} |
-
- ${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)
- }
-
`;
- 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) + "%";
+}