Make the "True Retention" table pretty (#3640)

* Make the True Retention table pretty

* Hide absolute pass/fail table for 'all'

* Run './ninja format'

* Manually run prettier on Svelte 5 components

* Refactor to not use {#snippet}

* Fix lint to pass check:eslint

* Fix lint to pass check:svelte

* Rename t9n -> tr to follow code style

* Replace hard-coded string with a translation string

* Use assertUnreachable(...) for exhaustively matching enum
This commit is contained in:
Ross Brown 2024-12-18 13:41:57 +00:00 committed by GitHub
parent e7fff9eba0
commit 5637390b50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 362 additions and 90 deletions

View file

@ -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

View file

@ -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();
</script>
<Graph {title} {subtitle}>
{#if trueRetentionHtml}
<div class="true-retention-table">
{@html trueRetentionHtml}
</div>
<InputBox>
<label>
<input type="radio" bind:group={mode} value={DisplayMode.Young} />
{tr.statisticsCountsYoungCards()}
</label>
<label>
<input type="radio" bind:group={mode} value={DisplayMode.Mature} />
{tr.statisticsCountsMatureCards()}
</label>
<label>
<input type="radio" bind:group={mode} value={DisplayMode.Summary} />
{tr.statisticsTrueRetentionAll()}
</label>
</InputBox>
<div class="table-container">
{#if retentionData === null}
<div>{tr.statisticsNoData()}</div>
{:else if mode === DisplayMode.Young}
<TrueRetentionSingle
{revlogRange}
data={retentionData}
scope={Scope.Young}
/>
{:else if mode === DisplayMode.Mature}
<TrueRetentionSingle
{revlogRange}
data={retentionData}
scope={Scope.Mature}
/>
{:else if mode === DisplayMode.All}
<TrueRetentionSingle {revlogRange} data={retentionData} scope={Scope.All} />
{:else if mode === DisplayMode.Summary}
<TrueRetentionCombined {revlogRange} data={retentionData} />
{:else}
{assertUnreachable(mode)}
{/if}
</div>
</Graph>
<style>
.true-retention-table {
overflow-x: auto;
.table-container {
margin-top: 1rem;
overflow-x: auto;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -0,0 +1,101 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { localizedNumber } from "@tslib/i18n";
import { type RevlogRange } from "./graph-helpers";
import {
calculateRetentionPercentageString,
getRowData,
type PeriodTrueRetentionData,
type RowData,
} from "./true-retention";
interface Props {
revlogRange: RevlogRange;
data: PeriodTrueRetentionData;
}
const { revlogRange, data }: Props = $props();
const rowData: RowData[] = $derived(getRowData(data, revlogRange));
</script>
<table>
<thead>
<tr>
<td></td>
<th scope="col" class="col-header young">
{tr.statisticsCountsYoungCards()}
</th>
<th scope="col" class="col-header mature">
{tr.statisticsCountsMatureCards()}
</th>
<th scope="col" class="col-header total">
{tr.statisticsCountsTotalCards()}
</th>
<th scope="col" class="col-header count">
{tr.statisticsTrueRetentionCount()}
</th>
</tr>
</thead>
<tbody>
{#each rowData as row}
{@const totalPassed = row.data.youngPassed + row.data.maturePassed}
{@const totalFailed = row.data.youngFailed + row.data.matureFailed}
<tr>
<th scope="row" class="row-header">{row.title}</th>
<td class="young">
{calculateRetentionPercentageString(
row.data.youngPassed,
row.data.youngFailed,
)}
</td>
<td class="mature">
{calculateRetentionPercentageString(
row.data.maturePassed,
row.data.matureFailed,
)}
</td>
<td class="total">
{calculateRetentionPercentageString(totalPassed, totalFailed)}
</td>
<td class="count">{localizedNumber(totalPassed + totalFailed)}</td>
</tr>
{/each}
</tbody>
</table>
<style lang="scss">
@use "true-retention-base";
.young,
.mature,
.total,
.count {
text-align: right;
}
.young {
color: #64c476;
}
.mature {
color: #31a354;
}
.total {
color: var(--fg);
}
.count {
color: var(--fg-subtle);
}
</style>

View file

@ -0,0 +1,84 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { type RevlogRange } from "./graph-helpers";
import {
calculateRetentionPercentageString,
getFailed,
getPassed,
getRowData,
type PeriodTrueRetentionData,
type RowData,
type Scope,
} from "./true-retention";
import { localizedNumber } from "@tslib/i18n";
interface Props {
revlogRange: RevlogRange;
data: PeriodTrueRetentionData;
scope: Scope;
}
const { revlogRange, data, scope }: Props = $props();
const rowData: RowData[] = $derived(getRowData(data, revlogRange));
</script>
<table>
<thead>
<tr>
<td></td>
<th scope="col" class="col-header pass">
{tr.statisticsTrueRetentionPass()}
</th>
<th scope="col" class="col-header fail">
{tr.statisticsTrueRetentionFail()}
</th>
<th scope="col" class="col-header retention">
{tr.statisticsTrueRetentionRetention()}
</th>
</tr>
</thead>
<tbody>
{#each rowData as row}
{@const passed = getPassed(row.data, scope)}
{@const failed = getFailed(row.data, scope)}
<tr>
<th scope="row" class="row-header">{row.title}</th>
<td class="pass">{localizedNumber(passed)}</td>
<td class="fail">{localizedNumber(failed)}</td>
<td class="retention">
{calculateRetentionPercentageString(passed, failed)}
</td>
</tr>
{/each}
</tbody>
</table>
<style lang="scss">
@use "true-retention-base";
.pass,
.fail,
.retention {
text-align: right;
}
.pass {
color: #3bc464;
}
.fail {
color: #c43b3b;
}
.retention {
color: var(--fg);
}
</style>

View file

@ -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);
}

View file

@ -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 `
<tr>
<td class="trl">${period}</td>
<td class="trr">${localizedNumber(pass)}</td>
<td class="trr">${localizedNumber(fail)}</td>
<td class="trr">${retention}</td>
</tr>`;
}
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 = `
<style>
td.trl { border: 1px solid; text-align: left; }
td.trr { border: 1px solid; text-align: right; }
td.trc { border: 1px solid; text-align: center; }
table.true-retention { width: 100%; table-layout: fixed; }
colgroup col:first-child { width: 40% }
</style>
<table class="true-retention" cellspacing="0" cellpadding="2">
<colgroup><col><col><col></colgroup>
<tr>
<td class="trl"><b>${scopeRange(scope)}</b></td>
<td class="trr">${tr.statisticsTrueRetentionPass()}</td>
<td class="trr">${tr.statisticsTrueRetentionFail()}</td>
<td class="trr">${tr.statisticsTrueRetentionRetention()}</td>
</tr>
${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)
}
</table>`;
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) + "%";
}