Move more of the graph processing into the backend

The existing architecture serializes all cards and revlog entries in
the search range into a protobuf message, which the web frontend needs
to decode and then process. The thinking at the time was that this would
make it easier for add-ons to add extra graphs, but in the ~2.5 years
since the new graphs were introduced, no add-ons appear to have taken
advantage of it.

The cards and revlog entries can grow quite large on large collections -
on a collection I tested with approximately 2.5M reviews, the serialized
data is about 110MB, which is a lot to have to deserialize in JavaScript.

This commit shifts the preliminary processing of the data to the Rust end,
which means the data is able to be processed faster, and less needs to
be sent to the frontend. On the test collection above, this reduces the
serialized data from about 110MB to about 160KB, resulting in a more
than 2x performance improvement, and reducing frontend memory usage from
about 400MB to about 40MB.

This also makes #2043 more feasible - while it is still about 50-100%
slower than protobufjs, with the much smaller message size, the difference
is only about 10ms.
This commit is contained in:
Damien Elmes 2022-12-09 17:35:15 +10:00
parent fa625d7ad8
commit 37151213cd
24 changed files with 643 additions and 380 deletions

View file

@ -2,7 +2,7 @@
version = "0.0.0" version = "0.0.0"
authors = ["Ankitects Pty Ltd and contributors <https://help.ankiweb.net>"] authors = ["Ankitects Pty Ltd and contributors <https://help.ankiweb.net>"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
rust-version = "1.64" rust-version = "1.65"
edition = "2021" edition = "2021"
[workspace] [workspace]

View file

@ -56,14 +56,90 @@ message GraphsRequest {
} }
message GraphsResponse { message GraphsResponse {
repeated cards.Card cards = 1; message Added {
repeated RevlogEntry revlog = 2; map<int32, uint32> added = 1;
uint32 days_elapsed = 3; }
// Based on rollover hour message Intervals {
uint32 next_day_at_secs = 4; map<uint32, uint32> intervals = 1;
uint32 scheduler_version = 5; }
/// Seconds to add to UTC timestamps to get local time. message Eases {
int32 local_offset_secs = 7; map<uint32, uint32> eases = 1;
}
message FutureDue {
map<int32, uint32> future_due = 1;
bool have_backlog = 2;
}
message Today {
uint32 answer_count = 1;
uint32 answer_millis = 2;
uint32 correct_count = 3;
uint32 mature_correct = 4;
uint32 mature_count = 5;
uint32 learn_count = 6;
uint32 review_count = 7;
uint32 relearn_count = 8;
uint32 early_review_count = 9;
}
// each bucket is a 24 element vec
message Hours {
message Hour {
uint32 total = 1;
uint32 correct = 2;
}
repeated Hour one_month = 1;
repeated Hour three_months = 2;
repeated Hour one_year = 3;
repeated Hour all_time = 4;
}
message ReviewCountsAndTimes {
message Reviews {
uint32 learn = 1;
uint32 relearn = 2;
uint32 young = 3;
uint32 mature = 4;
uint32 filtered = 5;
}
map<int32, Reviews> count = 1;
map<int32, Reviews> time = 2;
}
// 4 element vecs for buttons 1-4
message Buttons {
message ButtonCounts {
repeated uint32 learning = 1;
repeated uint32 young = 2;
repeated uint32 mature = 3;
}
ButtonCounts one_month = 1;
ButtonCounts three_months = 2;
ButtonCounts one_year = 3;
ButtonCounts all_time = 4;
}
message CardCounts {
message Counts {
uint32 newCards = 1;
uint32 learn = 2;
uint32 relearn = 3;
uint32 young = 4;
uint32 mature = 5;
uint32 suspended = 6;
uint32 buried = 7;
}
// Buried/suspended cards are included in counts; suspended/buried counts
// are 0.
Counts including_inactive = 1;
// Buried/suspended cards are counted separately.
Counts excluding_inactive = 2;
}
Buttons buttons = 1;
CardCounts card_counts = 2;
Hours hours = 3;
Today today = 4;
Eases eases = 5;
Intervals intervals = 6;
FutureDue future_due = 7;
Added added = 8;
ReviewCountsAndTimes reviews = 9;
} }
message GraphPreferences { message GraphPreferences {

View file

@ -0,0 +1,19 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::GraphsContext;
use crate::pb::stats::graphs_response::Added;
impl GraphsContext {
pub(super) fn added_days(&self) -> Added {
let mut data = Added::default();
for card in &self.cards {
// this could perhaps be simplified; it currently tries to match the old TS code logic
let day = ((card.id.as_secs().elapsed_secs_since(self.next_day_start) as f64)
/ 86_400.0)
.ceil() as i32;
*data.added.entry(day).or_insert_with(Default::default) += 1;
}
data
}
}

View file

@ -0,0 +1,89 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::GraphsContext;
use crate::{
pb::stats::graphs_response::{buttons::ButtonCounts, Buttons},
revlog::{RevlogEntry, RevlogReviewKind},
};
impl GraphsContext {
pub(super) fn buttons(&self) -> Buttons {
let mut all_time = ButtonCounts {
learning: vec![0; 4],
young: vec![0; 4],
mature: vec![0; 4],
};
let mut conditional_buckets = vec![
(
self.next_day_start.adding_secs(-86_400 * 365),
all_time.clone(),
),
(
self.next_day_start.adding_secs(-86_400 * 90),
all_time.clone(),
),
(
self.next_day_start.adding_secs(-86_400 * 30),
all_time.clone(),
),
];
'outer: for review in &self.revlog {
let Some(interval_bucket) = interval_bucket(review) else { continue; };
let Some(button_idx) = button_index(review.button_chosen) else { continue; };
let review_secs = review.id.as_secs();
all_time.increment(interval_bucket, button_idx);
for (stamp, bucket) in &mut conditional_buckets {
if &review_secs < stamp {
continue 'outer;
}
bucket.increment(interval_bucket, button_idx);
}
}
Buttons {
one_month: Some(conditional_buckets.pop().unwrap().1),
three_months: Some(conditional_buckets.pop().unwrap().1),
one_year: Some(conditional_buckets.pop().unwrap().1),
all_time: Some(all_time),
}
}
}
#[derive(Clone, Copy)]
enum IntervalBucket {
Learning,
Young,
Mature,
}
impl ButtonCounts {
fn increment(&mut self, bucket: IntervalBucket, button_idx: usize) {
match bucket {
IntervalBucket::Learning => self.learning[button_idx] += 1,
IntervalBucket::Young => self.young[button_idx] += 1,
IntervalBucket::Mature => self.mature[button_idx] += 1,
}
}
}
fn interval_bucket(review: &RevlogEntry) -> Option<IntervalBucket> {
match review.review_kind {
RevlogReviewKind::Learning | RevlogReviewKind::Relearning | RevlogReviewKind::Filtered => {
Some(IntervalBucket::Learning)
}
RevlogReviewKind::Review => Some(if review.last_interval < 21 {
IntervalBucket::Young
} else {
IntervalBucket::Mature
}),
RevlogReviewKind::Manual => None,
}
}
fn button_index(button_chosen: u8) -> Option<usize> {
if (1..=4).contains(&button_chosen) {
Some((button_chosen - 1) as usize)
} else {
None
}
}

View file

@ -0,0 +1,54 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{
card::{Card, CardQueue, CardType},
pb::stats::graphs_response::{card_counts::Counts, CardCounts},
stats::graphs::GraphsContext,
};
impl GraphsContext {
pub(super) fn card_counts(&self) -> CardCounts {
let mut excluding_inactive = Counts::default();
let mut including_inactive = Counts::default();
for card in &self.cards {
match card.queue {
CardQueue::Suspended => {
excluding_inactive.suspended += 1;
}
CardQueue::SchedBuried | CardQueue::UserBuried => {
excluding_inactive.buried += 1;
}
_ => excluding_inactive.increment(card),
};
including_inactive.increment(card);
}
CardCounts {
excluding_inactive: Some(excluding_inactive),
including_inactive: Some(including_inactive),
}
}
}
impl Counts {
fn increment(&mut self, card: &Card) {
match card.ctype {
CardType::New => {
self.new_cards += 1;
}
CardType::Learn => {
self.learn += 1;
}
CardType::Review => {
if card.interval < 21 {
self.young += 1;
} else {
self.mature += 1;
}
}
CardType::Relearn => {
self.relearn += 1;
}
}
}
}

View file

@ -0,0 +1,19 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{card::CardType, pb::stats::graphs_response::Eases, stats::graphs::GraphsContext};
impl GraphsContext {
pub(super) fn eases(&self) -> Eases {
let mut data = Eases::default();
for card in &self.cards {
if matches!(card.ctype, CardType::Review | CardType::Relearn) {
*data
.eases
.entry((card.ease_factor / 10) as u32)
.or_insert_with(Default::default) += 1;
}
}
data
}
}

View file

@ -0,0 +1,39 @@
// 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 super::GraphsContext;
use crate::pb::stats::graphs_response::FutureDue;
impl GraphsContext {
pub(super) fn future_due(&self) -> FutureDue {
let mut have_backlog = false;
let mut due_by_day: HashMap<i32, u32> = Default::default();
for c in &self.cards {
if c.queue as i8 <= 0 {
continue;
}
// The extra original_due check covers lapsed cards, which have their due date updated on
// graduation.
let due = if c.is_filtered() && c.original_due != 0 {
c.original_due
} else {
c.due
};
let due_day = if c.is_intraday_learning() {
let offset = due as i64 - self.next_day_start.0;
((offset / 86_400) + 1) as i32
} else {
due - (self.days_elapsed as i32)
};
have_backlog |= due_day < 0;
*due_by_day.entry(due_day).or_default() += 1;
}
FutureDue {
future_due: due_by_day,
have_backlog,
}
}
}

View file

@ -0,0 +1,61 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{
pb::stats::graphs_response::{hours::Hour, Hours},
revlog::RevlogReviewKind,
stats::graphs::GraphsContext,
};
impl GraphsContext {
pub(super) fn hours(&self) -> Hours {
let mut data = Hours {
one_month: vec![Default::default(); 24],
three_months: vec![Default::default(); 24],
one_year: vec![Default::default(); 24],
all_time: vec![Default::default(); 24],
};
let mut conditional_buckets = [
(
self.next_day_start.adding_secs(-86_400 * 365),
&mut data.one_year,
),
(
self.next_day_start.adding_secs(-86_400 * 90),
&mut data.three_months,
),
(
self.next_day_start.adding_secs(-86_400 * 30),
&mut data.one_month,
),
];
'outer: for review in &self.revlog {
if matches!(
review.review_kind,
RevlogReviewKind::Filtered | RevlogReviewKind::Manual
) {
continue;
}
let review_secs = review.id.as_secs();
let hour = (((review_secs.0 + self.local_offset_secs) / 3600) % 24) as usize;
let correct = review.button_chosen > 1;
data.all_time[hour].increment(correct);
for (stamp, bucket) in &mut conditional_buckets {
if &review_secs < stamp {
continue 'outer;
}
bucket[hour].increment(correct)
}
}
data
}
}
impl Hour {
pub(crate) fn increment(&mut self, correct: bool) {
self.total += 1;
if correct {
self.correct += 1;
}
}
}

View file

@ -0,0 +1,19 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{card::CardType, pb::stats::graphs_response::Intervals, stats::graphs::GraphsContext};
impl GraphsContext {
pub(super) fn intervals(&self) -> Intervals {
let mut data = Intervals::default();
for card in &self.cards {
if matches!(card.ctype, CardType::Review | CardType::Relearn) {
*data
.intervals
.entry(card.interval)
.or_insert_with(Default::default) += 1;
}
}
data
}
}

View file

@ -1,6 +1,16 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod added;
mod buttons;
mod card_counts;
mod eases;
mod future_due;
mod hours;
mod intervals;
mod reviews;
mod today;
use crate::{ use crate::{
config::{BoolKey, Weekday}, config::{BoolKey, Weekday},
pb, pb,
@ -9,6 +19,14 @@ use crate::{
search::SortMode, search::SortMode,
}; };
struct GraphsContext {
revlog: Vec<RevlogEntry>,
cards: Vec<Card>,
next_day_start: TimestampSecs,
days_elapsed: u32,
local_offset_secs: i64,
}
impl Collection { impl Collection {
pub(crate) fn graph_data_for_search( pub(crate) fn graph_data_for_search(
&mut self, &mut self,
@ -29,26 +47,33 @@ impl Collection {
} else { } else {
TimestampSecs(0) TimestampSecs(0)
}; };
let offset = self.local_utc_offset_for_user()?; let offset = self.local_utc_offset_for_user()?;
let local_offset_secs = offset.local_minus_utc() as i64; let local_offset_secs = offset.local_minus_utc() as i64;
let cards = self.storage.all_searched_cards()?;
let revlog = if all { let revlog = if all {
self.storage.get_all_revlog_entries(revlog_start)? self.storage.get_all_revlog_entries(revlog_start)?
} else { } else {
self.storage self.storage
.get_pb_revlog_entries_for_searched_cards(revlog_start)? .get_revlog_entries_for_searched_cards_after_stamp(revlog_start)?
}; };
let ctx = GraphsContext {
Ok(pb::stats::GraphsResponse {
cards: cards.into_iter().map(Into::into).collect(),
revlog, revlog,
days_elapsed: timing.days_elapsed, days_elapsed: timing.days_elapsed,
next_day_at_secs: timing.next_day_at.0 as u32, cards: self.storage.all_searched_cards()?,
scheduler_version: self.scheduler_version() as u32, next_day_start: timing.next_day_at,
local_offset_secs: local_offset_secs as i32, local_offset_secs,
}) };
let resp = pb::stats::GraphsResponse {
added: Some(ctx.added_days()),
reviews: Some(ctx.review_counts_and_times()),
future_due: Some(ctx.future_due()),
intervals: Some(ctx.intervals()),
eases: Some(ctx.eases()),
today: Some(ctx.today()),
hours: Some(ctx.hours()),
buttons: Some(ctx.buttons()),
card_counts: Some(ctx.card_counts()),
};
Ok(resp)
} }
pub(crate) fn get_graph_preferences(&self) -> pb::stats::GraphPreferences { pub(crate) fn get_graph_preferences(&self) -> pb::stats::GraphPreferences {
@ -79,19 +104,3 @@ impl Collection {
Ok(()) Ok(())
} }
} }
impl From<RevlogEntry> for pb::stats::RevlogEntry {
fn from(e: RevlogEntry) -> Self {
pb::stats::RevlogEntry {
id: e.id.0,
cid: e.cid.0,
usn: e.usn.0,
button_chosen: e.button_chosen as u32,
interval: e.interval,
last_interval: e.last_interval,
ease_factor: e.ease_factor,
taken_millis: e.taken_millis,
review_kind: e.review_kind as i32,
}
}
}

View file

@ -0,0 +1,44 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::GraphsContext;
use crate::{pb::stats::graphs_response::ReviewCountsAndTimes, revlog::RevlogReviewKind};
impl GraphsContext {
pub(super) fn review_counts_and_times(&self) -> ReviewCountsAndTimes {
let mut data = ReviewCountsAndTimes::default();
for review in &self.revlog {
if review.review_kind == RevlogReviewKind::Manual {
continue;
}
let day = (review.id.as_secs().elapsed_secs_since(self.next_day_start) / 86_400) as i32;
let count = data.count.entry(day).or_insert_with(Default::default);
let time = data.time.entry(day).or_insert_with(Default::default);
match review.review_kind {
RevlogReviewKind::Learning => {
count.learn += 1;
time.learn += review.taken_millis;
}
RevlogReviewKind::Relearning => {
count.relearn += 1;
time.relearn += review.taken_millis;
}
RevlogReviewKind::Review => {
if review.last_interval < 21 {
count.young += 1;
time.young += review.taken_millis;
} else {
count.mature += 1;
time.mature += review.taken_millis;
}
}
RevlogReviewKind::Filtered => {
count.filtered += 1;
time.filtered += review.taken_millis;
}
RevlogReviewKind::Manual => unreachable!(),
}
}
data
}
}

View file

@ -0,0 +1,44 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{
pb::stats::graphs_response::Today, revlog::RevlogReviewKind, stats::graphs::GraphsContext,
};
impl GraphsContext {
pub(super) fn today(&self) -> Today {
let mut today = Today::default();
let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0;
for review in self.revlog.iter().rev() {
if review.id.0 < start_of_today_ms {
break;
}
if review.review_kind == RevlogReviewKind::Manual {
continue;
}
// total
today.answer_count += 1;
today.answer_millis += review.taken_millis;
// correct
if review.button_chosen > 1 {
today.correct_count += 1;
}
// mature
if review.last_interval >= 21 {
today.mature_count += 1;
if review.button_chosen > 1 {
today.mature_correct += 1;
}
}
// type counts
match review.review_kind {
RevlogReviewKind::Learning => today.learn_count += 1,
RevlogReviewKind::Review => today.review_count += 1,
RevlogReviewKind::Relearning => today.relearn_count += 1,
RevlogReviewKind::Filtered => today.early_review_count += 1,
RevlogReviewKind::Manual => unreachable!(),
}
}
today
}
}

View file

@ -12,7 +12,6 @@ use rusqlite::{
use super::SqliteStorage; use super::SqliteStorage;
use crate::{ use crate::{
error::Result, error::Result,
pb,
prelude::*, prelude::*,
revlog::{RevlogEntry, RevlogReviewKind}, revlog::{RevlogEntry, RevlogReviewKind},
}; };
@ -110,16 +109,16 @@ impl SqliteStorage {
.collect() .collect()
} }
pub(crate) fn get_pb_revlog_entries_for_searched_cards( pub(crate) fn get_revlog_entries_for_searched_cards_after_stamp(
&self, &self,
after: TimestampSecs, after: TimestampSecs,
) -> Result<Vec<pb::stats::RevlogEntry>> { ) -> Result<Vec<RevlogEntry>> {
self.db self.db
.prepare_cached(concat!( .prepare_cached(concat!(
include_str!("get.sql"), include_str!("get.sql"),
" where cid in (select cid from search_cids) and id >= ?" " where cid in (select cid from search_cids) and id >= ?"
))? ))?
.query_and_then([after.0 * 1000], |r| row_to_revlog_entry(r).map(Into::into))? .query_and_then([after.0 * 1000], row_to_revlog_entry)?
.collect() .collect()
} }
@ -133,11 +132,7 @@ impl SqliteStorage {
.collect() .collect()
} }
/// This includes entries from deleted cards. pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> {
pub(crate) fn get_all_revlog_entries(
&self,
after: TimestampSecs,
) -> Result<Vec<pb::stats::RevlogEntry>> {
self.db self.db
.prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))? .prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))?
.query_and_then([after.0 * 1000], |r| row_to_revlog_entry(r).map(Into::into))? .query_and_then([after.0 * 1000], |r| row_to_revlog_entry(r).map(Into::into))?

View file

@ -6,25 +6,21 @@
*/ */
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import type { Cards, Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { dayLabel } from "@tslib/time"; import { dayLabel } from "@tslib/time";
import type { Bin } from "d3"; import type { Bin } from "d3";
import { bin, extent, interpolateBlues, scaleLinear, scaleSequential, sum } from "d3"; import { bin, interpolateBlues, min, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers"; import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { GraphRange } from "./graph-helpers"; import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph"; import type { HistogramData } from "./histogram-graph";
export interface GraphData { export interface GraphData {
daysAdded: number[]; daysAdded: Map<number, number>;
} }
export function gatherData(data: Stats.GraphsResponse): GraphData { export function gatherData(data: Stats.GraphsResponse): GraphData {
const daysAdded = (data.cards as Cards.Card[]).map((card) => { return { daysAdded: numericMap(data.added!.added) };
const elapsedSecs = (card.id as number) / 1000 - data.nextDayAtSecs;
return Math.ceil(elapsedSecs / 86400);
});
return { daysAdded };
} }
function makeQuery(start: number, end: number): string { function makeQuery(start: number, end: number): string {
@ -45,13 +41,12 @@ export function buildHistogram(
browserLinksSupported: boolean, browserLinksSupported: boolean,
): [HistogramData | null, TableDatum[]] { ): [HistogramData | null, TableDatum[]] {
// get min/max // get min/max
const total = data.daysAdded.length; const total = data.daysAdded.size;
if (!total) { if (!total) {
return [null, []]; return [null, []];
} }
const [xMinOrig] = extent(data.daysAdded); let xMin: number;
let xMin = xMinOrig;
// cap max to selected range // cap max to selected range
switch (range) { switch (range) {
@ -65,6 +60,7 @@ export function buildHistogram(
xMin = -365; xMin = -365;
break; break;
case GraphRange.AllTime: case GraphRange.AllTime:
xMin = min(data.daysAdded.keys())!;
break; break;
} }
const xMax = 1; const xMax = 1;
@ -72,8 +68,11 @@ export function buildHistogram(
const scale = scaleLinear().domain([xMin!, xMax]); const scale = scaleLinear().domain([xMin!, xMax]);
const bins = bin() const bins = bin()
.value((m) => {
return m[0];
})
.domain(scale.domain() as any) .domain(scale.domain() as any)
.thresholds(scale.ticks(desiredBars))(data.daysAdded); .thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
// empty graph? // empty graph?
if (!sum(bins, (bin) => bin.length)) { if (!sum(bins, (bin) => bin.length)) {
@ -124,6 +123,7 @@ export function buildHistogram(
hoverText, hoverText,
onClick: browserLinksSupported ? onClick : null, onClick: browserLinksSupported ? onClick : null,
colourScale, colourScale,
binValue: getNumericMapBinValue,
showArea: true, showArea: true,
}, },
tableData, tableData,

View file

@ -7,7 +7,7 @@
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import { Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { import {
axisBottom, axisBottom,
axisLeft, axisLeft,
@ -20,11 +20,13 @@ import {
sum, sum,
} from "d3"; } from "d3";
import type { GraphBounds, GraphRange } from "./graph-helpers"; import type { GraphBounds } from "./graph-helpers";
import { millisecondCutoffForRange, setDataAvailable } from "./graph-helpers"; import { GraphRange } from "./graph-helpers";
import { setDataAvailable } from "./graph-helpers";
import { hideTooltip, showTooltip } from "./tooltip"; import { hideTooltip, showTooltip } from "./tooltip";
type ButtonCounts = [number, number, number, number]; /// 4 element array
type ButtonCounts = number[];
export interface GraphData { export interface GraphData {
learning: ButtonCounts; learning: ButtonCounts;
@ -32,48 +34,18 @@ export interface GraphData {
mature: ButtonCounts; mature: ButtonCounts;
} }
const ReviewKind = Stats.RevlogEntry.ReviewKind;
export function gatherData(data: Stats.GraphsResponse, range: GraphRange): GraphData { export function gatherData(data: Stats.GraphsResponse, range: GraphRange): GraphData {
const cutoff = millisecondCutoffForRange(range, data.nextDayAtSecs); const buttons = data.buttons!;
const learning: ButtonCounts = [0, 0, 0, 0]; switch (range) {
const young: ButtonCounts = [0, 0, 0, 0]; case GraphRange.Month:
const mature: ButtonCounts = [0, 0, 0, 0]; return buttons.oneMonth!;
case GraphRange.ThreeMonths:
for (const review of data.revlog as Stats.RevlogEntry[]) { return buttons.threeMonths!;
if (cutoff && (review.id as number) < cutoff) { case GraphRange.Year:
continue; return buttons.oneYear!;
} case GraphRange.AllTime:
return buttons.allTime!;
let buttonNum = review.buttonChosen;
if (buttonNum <= 0 || buttonNum > 4) {
continue;
}
let buttons = learning;
switch (review.reviewKind) {
case ReviewKind.LEARNING:
case ReviewKind.RELEARNING:
// V1 scheduler only had 3 buttons in learning
if (buttonNum === 4 && data.schedulerVersion === 1) {
buttonNum = 3;
}
break;
case ReviewKind.REVIEW:
if (review.lastInterval < 21) {
buttons = young;
} else {
buttons = mature;
}
break;
case ReviewKind.FILTERED:
break;
}
buttons[buttonNum - 1] += 1;
} }
return { learning, young, mature };
} }
type GroupKind = "learning" | "young" | "mature"; type GroupKind = "learning" | "young" | "mature";

View file

@ -48,19 +48,11 @@ export function gatherData(
data: Stats.GraphsResponse, data: Stats.GraphsResponse,
firstDayOfWeek: WeekdayType, firstDayOfWeek: WeekdayType,
): GraphData { ): GraphData {
const reviewCount = new Map<number, number>(); const reviewCount = new Map(
Object.entries(data.reviews!.count).map(([k, v]) => {
for (const review of data.revlog as Stats.RevlogEntry[]) { return [Number(k), v.learn + v.relearn + v.mature + v.filtered + v.young];
if (review.buttonChosen == 0) { }),
continue; );
}
const day = Math.ceil(
((review.id as number) / 1000 - data.nextDayAtSecs) / 86400,
);
const count = reviewCount.get(day) ?? 0;
reviewCount.set(day, count + 1);
}
const timeFunction = timeFunctionForDay(firstDayOfWeek); const timeFunction = timeFunctionForDay(firstDayOfWeek);
const weekdayLabels: number[] = []; const weekdayLabels: number[] = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {

View file

@ -5,10 +5,9 @@
@typescript-eslint/no-explicit-any: "off", @typescript-eslint/no-explicit-any: "off",
*/ */
import { CardQueue, CardType } from "@tslib/cards";
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import type { Cards, Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { import {
arc, arc,
cumsum, cumsum,
@ -20,6 +19,7 @@ import {
schemeOranges, schemeOranges,
schemeReds, schemeReds,
select, select,
sum,
} from "d3"; } from "d3";
import type { GraphBounds } from "./graph-helpers"; import type { GraphBounds } from "./graph-helpers";
@ -41,83 +41,44 @@ const barColours = [
"grey", /* buried */ "grey", /* buried */
]; ];
function countCards(cards: Cards.ICard[], separateInactive: boolean): Count[] { function countCards(data: Stats.GraphsResponse, separateInactive: boolean): Count[] {
let newCards = 0; const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!;
let learn = 0;
let relearn = 0;
let young = 0;
let mature = 0;
let suspended = 0;
let buried = 0;
for (const card of cards as Cards.Card[]) {
if (separateInactive) {
switch (card.queue) {
case CardQueue.Suspended:
suspended += 1;
continue;
case CardQueue.SchedBuried:
case CardQueue.UserBuried:
buried += 1;
continue;
}
}
switch (card.ctype) {
case CardType.New:
newCards += 1;
break;
case CardType.Learn:
learn += 1;
break;
case CardType.Review:
if (card.interval < 21) {
young += 1;
} else {
mature += 1;
}
break;
case CardType.Relearn:
relearn += 1;
break;
}
}
const extraQuery = separateInactive ? "AND -(\"is:buried\" OR \"is:suspended\")" : ""; const extraQuery = separateInactive ? "AND -(\"is:buried\" OR \"is:suspended\")" : "";
const counts: Count[] = [ const counts: Count[] = [
[tr.statisticsCountsNewCards(), newCards, true, `"is:new"${extraQuery}`], [tr.statisticsCountsNewCards(), countData.newCards, true, `"is:new"${extraQuery}`],
[ [
tr.statisticsCountsLearningCards(), tr.statisticsCountsLearningCards(),
learn, countData.learn,
true, true,
`(-"is:review" AND "is:learn")${extraQuery}`, `(-"is:review" AND "is:learn")${extraQuery}`,
], ],
[ [
tr.statisticsCountsRelearningCards(), tr.statisticsCountsRelearningCards(),
relearn, countData.relearn,
true, true,
`("is:review" AND "is:learn")${extraQuery}`, `("is:review" AND "is:learn")${extraQuery}`,
], ],
[ [
tr.statisticsCountsYoungCards(), tr.statisticsCountsYoungCards(),
young, countData.young,
true, true,
`("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`, `("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`,
], ],
[ [
tr.statisticsCountsMatureCards(), tr.statisticsCountsMatureCards(),
mature, countData.mature,
true, true,
`("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`, `("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`,
], ],
[ [
tr.statisticsCountsSuspendedCards(), tr.statisticsCountsSuspendedCards(),
suspended, countData.suspended,
separateInactive, separateInactive,
"\"is:suspended\"", "\"is:suspended\"",
], ],
[tr.statisticsCountsBuriedCards(), buried, separateInactive, "\"is:buried\""], [tr.statisticsCountsBuriedCards(), countData.buried, separateInactive, "\"is:buried\""],
]; ];
return counts; return counts;
@ -127,8 +88,8 @@ export function gatherData(
data: Stats.GraphsResponse, data: Stats.GraphsResponse,
separateInactive: boolean, separateInactive: boolean,
): GraphData { ): GraphData {
const totalCards = localizedNumber(data.cards.length); const counts = countCards(data, separateInactive);
const counts = countCards(data.cards, separateInactive); const totalCards = localizedNumber(sum(counts, e => e[1]));
return { return {
title: tr.statisticsCountsTitle(), title: tr.statisticsCountsTitle(),

View file

@ -5,25 +5,22 @@
@typescript-eslint/no-explicit-any: "off", @typescript-eslint/no-explicit-any: "off",
*/ */
import { CardType } from "@tslib/cards";
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import type { Cards, Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import type { Bin, ScaleLinear } from "d3"; import type { Bin, ScaleLinear } from "d3";
import { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; import { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers"; import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { getNumericMapBinValue, numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph"; import type { HistogramData } from "./histogram-graph";
export interface GraphData { export interface GraphData {
eases: number[]; eases: Map<number, number>;
} }
export function gatherData(data: Stats.GraphsResponse): GraphData { export function gatherData(data: Stats.GraphsResponse): GraphData {
const eases = (data.cards as Cards.Card[]) return { eases: numericMap(data.eases!.eases) };
.filter((c) => [CardType.Review, CardType.Relearn].includes(c.ctype))
.map((c) => c.easeFactor / 10);
return { eases };
} }
function makeQuery(start: number, end: number): string { function makeQuery(start: number, end: number): string {
@ -68,11 +65,10 @@ export function prepareData(
): [HistogramData | null, TableDatum[]] { ): [HistogramData | null, TableDatum[]] {
// get min/max // get min/max
const allEases = data.eases; const allEases = data.eases;
if (!allEases.length) { if (!allEases.size) {
return [null, []]; return [null, []];
} }
const total = allEases.length; const [, origXMax] = extent(allEases.keys());
const [, origXMax] = extent(allEases);
const xMin = 130; const xMin = 130;
const xMax = origXMax! + 1; const xMax = origXMax! + 1;
const desiredBars = 20; const desiredBars = 20;
@ -80,8 +76,12 @@ export function prepareData(
const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);
const bins = bin() const bins = bin()
.value((m) => {
return m[0];
})
.domain(scale.domain() as [number, number]) .domain(scale.domain() as [number, number])
.thresholds(ticks)(allEases); .thresholds(ticks)(allEases.entries() as any);
const total = sum(bins as any, getNumericMapBinValue);
const colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]); const colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]);
@ -90,7 +90,7 @@ export function prepareData(
const maxPct = Math.floor(bin.x1!); const maxPct = Math.floor(bin.x1!);
const percent = maxPct - minPct <= 10 ? `${bin.x0}%` : `${bin.x0}%-${bin.x1}%`; const percent = maxPct - minPct <= 10 ? `${bin.x0}%` : `${bin.x0}%-${bin.x1}%`;
return tr.statisticsCardEaseTooltip({ return tr.statisticsCardEaseTooltip({
cards: bin.length, cards: getNumericMapBinValue(bin as any),
percent, percent,
}); });
} }
@ -106,7 +106,7 @@ export function prepareData(
const tableData = [ const tableData = [
{ {
label: tr.statisticsAverageEase(), label: tr.statisticsAverageEase(),
value: xTickFormat(sum(allEases) / total), value: xTickFormat(sum(Array.from(allEases.entries()).map(([k, v]) => k * v)) / total),
}, },
]; ];
@ -119,6 +119,7 @@ export function prepareData(
onClick: browserLinksSupported ? onClick : null, onClick: browserLinksSupported ? onClick : null,
colourScale, colourScale,
showArea: false, showArea: false,
binValue: getNumericMapBinValue,
xTickFormat, xTickFormat,
}, },
tableData, tableData,

View file

@ -5,16 +5,15 @@
@typescript-eslint/no-explicit-any: "off", @typescript-eslint/no-explicit-any: "off",
*/ */
import { CardType } from "@tslib/cards";
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import type { Cards, Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { dayLabel } from "@tslib/time"; import { dayLabel } from "@tslib/time";
import type { Bin } from "d3"; import type { Bin } from "d3";
import { bin, extent, interpolateGreens, rollup, scaleLinear, scaleSequential, sum } from "d3"; import { bin, extent, interpolateGreens, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers"; import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { GraphRange } from "./graph-helpers"; import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph"; import type { HistogramData } from "./histogram-graph";
export interface GraphData { export interface GraphData {
@ -23,44 +22,8 @@ export interface GraphData {
} }
export function gatherData(data: Stats.GraphsResponse): GraphData { export function gatherData(data: Stats.GraphsResponse): GraphData {
const isIntradayLearning = (card: Cards.Card, due: number): boolean => { const msg = data.futureDue!;
return ( return { dueCounts: numericMap(msg.futureDue), haveBacklog: msg.haveBacklog };
[CardType.Learn, CardType.Relearn].includes(card.ctype)
&& due > 1_000_000_000
);
};
let haveBacklog = false;
const due = (data.cards as Cards.Card[])
.filter((c: Cards.Card) => c.queue > 0)
.map((c: Cards.Card) => {
// - testing just odue fails on day 1
// - testing just odid fails on lapsed cards that
// have due calculated at regraduation time
const due = c.originalDeckId && c.originalDue ? c.originalDue : c.due;
let dueDay: number;
if (isIntradayLearning(c, due)) {
const offset = due - data.nextDayAtSecs;
dueDay = Math.floor(offset / 86_400) + 1;
} else {
dueDay = due - data.daysElapsed;
}
haveBacklog = haveBacklog || dueDay < 0;
return dueDay;
});
const dueCounts = rollup(
due,
(v) => v.length,
(d) => d,
);
return { dueCounts, haveBacklog };
}
function binValue(d: Bin<Map<number, number>, number>): number {
return sum(d, (d) => d[1]);
} }
export interface FutureDueResponse { export interface FutureDueResponse {
@ -133,7 +96,7 @@ export function buildHistogram(
const adjustedRange = scaleLinear().range([0.7, 0.3]); const adjustedRange = scaleLinear().range([0.7, 0.3]);
const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]); const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]);
const total = sum(bins as any, binValue); const total = sum(bins as any, getNumericMapBinValue);
function hoverText( function hoverText(
bin: Bin<number, number>, bin: Bin<number, number>,
@ -142,7 +105,7 @@ export function buildHistogram(
): string { ): string {
const days = dayLabel(bin.x0!, bin.x1!); const days = dayLabel(bin.x0!, bin.x1!);
const cards = tr.statisticsCardsDue({ const cards = tr.statisticsCardsDue({
cards: binValue(bin as any), cards: getNumericMapBinValue(bin as any),
}); });
const totalLabel = tr.statisticsRunningTotal(); const totalLabel = tr.statisticsRunningTotal();
@ -185,7 +148,7 @@ export function buildHistogram(
onClick: browserLinksSupported ? onClick : null, onClick: browserLinksSupported ? onClick : null,
showArea: true, showArea: true,
colourScale, colourScale,
binValue, binValue: getNumericMapBinValue,
xTickFormat, xTickFormat,
}, },
tableData, tableData,

View file

@ -6,7 +6,8 @@
@typescript-eslint/ban-ts-comment: "off" */ @typescript-eslint/ban-ts-comment: "off" */
import type { Cards, Stats } from "@tslib/proto"; import type { Cards, Stats } from "@tslib/proto";
import type { Selection } from "d3"; import type { Bin, Selection } from "d3";
import { sum } from "d3";
// amount of data to fetch from backend // amount of data to fetch from backend
export enum RevlogRange { export enum RevlogRange {
@ -97,3 +98,13 @@ export type SearchDispatch = <EventKey extends Extract<keyof SearchEventMap, str
type: EventKey, type: EventKey,
detail: SearchEventMap[EventKey], detail: SearchEventMap[EventKey],
) => void; ) => void;
/// Convert a protobuf map that protobufjs represents as an object with string
/// keys into a Map with numeric keys.
export function numericMap<T>(obj: { [k: string]: T }): Map<number, T> {
return new Map(Object.entries(obj).map(([k, v]) => [Number(k), v]));
}
export function getNumericMapBinValue(d: Bin<Map<number, number>, number>): number {
return sum(d, (d) => d[1]);
}

View file

@ -7,7 +7,7 @@
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import { Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { import {
area, area,
axisBottom, axisBottom,
@ -22,8 +22,8 @@ import {
select, select,
} from "d3"; } from "d3";
import type { GraphBounds, GraphRange } from "./graph-helpers"; import type { GraphBounds } from "./graph-helpers";
import { millisecondCutoffForRange, setDataAvailable } from "./graph-helpers"; import { GraphRange, setDataAvailable } from "./graph-helpers";
import { oddTickClass } from "./graph-styles"; import { oddTickClass } from "./graph-styles";
import { hideTooltip, showTooltip } from "./tooltip"; import { hideTooltip, showTooltip } from "./tooltip";
@ -33,38 +33,22 @@ interface Hour {
correctCount: number; correctCount: number;
} }
const ReviewKind = Stats.RevlogEntry.ReviewKind;
function gatherData(data: Stats.GraphsResponse, range: GraphRange): Hour[] { function gatherData(data: Stats.GraphsResponse, range: GraphRange): Hour[] {
const hours = [...Array(24)].map((_n, idx: number) => { function convert(hours: Stats.GraphsResponse.Hours.IHour[]): Hour[] {
return { hour: idx, totalCount: 0, correctCount: 0 } as Hour; return hours.map((e, idx) => {
}); return { hour: idx, totalCount: e.total!, correctCount: e.correct! };
const cutoff = millisecondCutoffForRange(range, data.nextDayAtSecs); });
}
for (const review of data.revlog as Stats.RevlogEntry[]) { switch (range) {
switch (review.reviewKind) { case GraphRange.Month:
case ReviewKind.LEARNING: return convert(data.hours!.oneMonth);
case ReviewKind.REVIEW: case GraphRange.ThreeMonths:
case ReviewKind.RELEARNING: return convert(data.hours!.threeMonths);
break; // from switch case GraphRange.Year:
case ReviewKind.FILTERED: return convert(data.hours!.oneYear);
case ReviewKind.MANUAL: case GraphRange.AllTime:
continue; // next loop iteration return convert(data.hours!.allTime);
}
if (cutoff && (review.id as number) < cutoff) {
continue;
}
const hour = Math.floor(
(((review.id as number) / 1000 + data.localOffsetSecs) / 3600) % 24,
);
hours[hour].totalCount += 1;
if (review.buttonChosen != 1) {
hours[hour].correctCount += 1;
}
} }
return hours;
} }
export function renderHours( export function renderHours(

View file

@ -5,15 +5,15 @@
@typescript-eslint/no-explicit-any: "off", @typescript-eslint/no-explicit-any: "off",
*/ */
import { CardType } from "@tslib/cards";
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import type { Cards, Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { timeSpan } from "@tslib/time"; import { timeSpan } from "@tslib/time";
import type { Bin } from "d3"; import type { Bin } from "d3";
import { bin, extent, interpolateBlues, mean, quantile, scaleLinear, scaleSequential, sum } from "d3"; import { bin, extent, interpolateBlues, mean, quantile, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers"; import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph"; import type { HistogramData } from "./histogram-graph";
export interface IntervalGraphData { export interface IntervalGraphData {
@ -28,10 +28,19 @@ export enum IntervalRange {
} }
export function gatherIntervalData(data: Stats.GraphsResponse): IntervalGraphData { export function gatherIntervalData(data: Stats.GraphsResponse): IntervalGraphData {
const intervals = (data.cards as Cards.Card[]) // This could be made more efficient - this graph currently expects a flat list of individual intervals which it
.filter((c) => [CardType.Review, CardType.Relearn].includes(c.ctype)) // uses to calculate a percentile and then converts into a histogram, and the percentile/histogram calculations
.map((c) => c.interval); // in JS are relatively slow.
return { intervals }; const map = numericMap(data.intervals!.intervals);
const totalCards = sum(map, ([_k, v]) => v);
const allIntervals: number[] = Array(totalCards);
let position = 0;
for (const entry of map.entries()) {
allIntervals.fill(entry[0], position, position + entry[1]);
position += entry[1];
}
allIntervals.sort((a, b) => a - b);
return { intervals: allIntervals };
} }
export function intervalLabel( export function intervalLabel(

View file

@ -7,7 +7,7 @@
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import { Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { dayLabel, timeSpan } from "@tslib/time"; import { dayLabel, timeSpan } from "@tslib/time";
import type { Bin, ScaleSequential } from "d3"; import type { Bin, ScaleSequential } from "d3";
import { import {
@ -32,7 +32,7 @@ import {
} from "d3"; } from "d3";
import type { GraphBounds, TableDatum } from "./graph-helpers"; import type { GraphBounds, TableDatum } from "./graph-helpers";
import { GraphRange, setDataAvailable } from "./graph-helpers"; import { GraphRange, numericMap, setDataAvailable } from "./graph-helpers";
import { hideTooltip, showTooltip } from "./tooltip"; import { hideTooltip, showTooltip } from "./tooltip";
interface Reviews { interface Reviews {
@ -49,51 +49,10 @@ export interface GraphData {
reviewTime: Map<number, Reviews>; reviewTime: Map<number, Reviews>;
} }
const ReviewKind = Stats.RevlogEntry.ReviewKind;
type BinType = Bin<Map<number, Reviews[]>, number>; type BinType = Bin<Map<number, Reviews[]>, number>;
export function gatherData(data: Stats.GraphsResponse): GraphData { export function gatherData(data: Stats.GraphsResponse): GraphData {
const reviewCount = new Map<number, Reviews>(); return { reviewCount: numericMap(data.reviews!.count), reviewTime: numericMap(data.reviews!.time) };
const reviewTime = new Map<number, Reviews>();
const empty = { mature: 0, young: 0, learn: 0, relearn: 0, filtered: 0 };
for (const review of data.revlog as Stats.RevlogEntry[]) {
if (review.reviewKind == ReviewKind.MANUAL) {
// don't count days with only manual scheduling
continue;
}
const day = Math.ceil(
((review.id as number) / 1000 - data.nextDayAtSecs) / 86400,
);
const countEntry = reviewCount.get(day) ?? reviewCount.set(day, { ...empty }).get(day)!;
const timeEntry = reviewTime.get(day) ?? reviewTime.set(day, { ...empty }).get(day)!;
switch (review.reviewKind) {
case ReviewKind.LEARNING:
countEntry.learn += 1;
timeEntry.learn += review.takenMillis;
break;
case ReviewKind.RELEARNING:
countEntry.relearn += 1;
timeEntry.relearn += review.takenMillis;
break;
case ReviewKind.REVIEW:
if (review.lastInterval < 21) {
countEntry.young += 1;
timeEntry.young += review.takenMillis;
} else {
countEntry.mature += 1;
timeEntry.mature += review.takenMillis;
}
break;
case ReviewKind.FILTERED:
countEntry.filtered += 1;
timeEntry.filtered += review.takenMillis;
break;
}
}
return { reviewCount, reviewTime };
} }
enum BinIndex { enum BinIndex {

View file

@ -3,7 +3,7 @@
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n"; import { localizedNumber } from "@tslib/i18n";
import { Stats } from "@tslib/proto"; import type { Stats } from "@tslib/proto";
import { studiedToday } from "@tslib/time"; import { studiedToday } from "@tslib/time";
export interface TodayData { export interface TodayData {
@ -11,91 +11,34 @@ export interface TodayData {
lines: string[]; lines: string[];
} }
const ReviewKind = Stats.RevlogEntry.ReviewKind;
export function gatherData(data: Stats.GraphsResponse): TodayData { export function gatherData(data: Stats.GraphsResponse): TodayData {
let answerCount = 0;
let answerMillis = 0;
let correctCount = 0;
let matureCorrect = 0;
let matureCount = 0;
let learnCount = 0;
let reviewCount = 0;
let relearnCount = 0;
let earlyReviewCount = 0;
const startOfTodayMillis = (data.nextDayAtSecs - 86400) * 1000;
for (const review of data.revlog as Stats.RevlogEntry[]) {
if (review.id < startOfTodayMillis) {
continue;
}
if (review.reviewKind == ReviewKind.MANUAL) {
continue;
}
// total
answerCount += 1;
answerMillis += review.takenMillis;
// correct
if (review.buttonChosen > 1) {
correctCount += 1;
}
// mature
if (review.lastInterval >= 21) {
matureCount += 1;
if (review.buttonChosen > 1) {
matureCorrect += 1;
}
}
// type counts
switch (review.reviewKind) {
case ReviewKind.LEARNING:
learnCount += 1;
break;
case ReviewKind.REVIEW:
reviewCount += 1;
break;
case ReviewKind.RELEARNING:
relearnCount += 1;
break;
case ReviewKind.FILTERED:
earlyReviewCount += 1;
break;
}
}
let lines: string[]; let lines: string[];
if (answerCount) { const today = data.today!;
const studiedTodayText = studiedToday(answerCount, answerMillis / 1000); if (today.answerCount) {
const againCount = answerCount - correctCount; const studiedTodayText = studiedToday(today.answerCount, today.answerMillis / 1000);
const againCount = today.answerCount - today.correctCount;
let againCountText = tr.statisticsTodayAgainCount(); let againCountText = tr.statisticsTodayAgainCount();
againCountText += ` ${againCount} (${ againCountText += ` ${againCount} (${
localizedNumber( localizedNumber(
(againCount / answerCount) * 100, (againCount / today.answerCount) * 100,
) )
}%)`; }%)`;
const typeCounts = tr.statisticsTodayTypeCounts({ const typeCounts = tr.statisticsTodayTypeCounts({
learnCount, learnCount: today.learnCount,
reviewCount, reviewCount: today.reviewCount,
relearnCount, relearnCount: today.relearnCount,
filteredCount: earlyReviewCount, filteredCount: today.earlyReviewCount,
}); });
let matureText: string; let matureText: string;
if (matureCount) { if (today.matureCount) {
matureText = tr.statisticsTodayCorrectMature({ matureText = tr.statisticsTodayCorrectMature({
correct: matureCorrect, correct: today.matureCorrect,
total: matureCount, total: today.matureCount,
percent: (matureCorrect / matureCount) * 100, percent: (today.matureCorrect / today.matureCount) * 100,
}); });
} else { } else {
matureText = tr.statisticsTodayNoMatureCards(); matureText = tr.statisticsTodayNoMatureCards();
} }
lines = [studiedTodayText, againCountText, typeCounts, matureText]; lines = [studiedTodayText, againCountText, typeCounts, matureText];
} else { } else {
lines = [tr.statisticsTodayNoCards()]; lines = [tr.statisticsTodayNoCards()];