mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
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:
parent
fa625d7ad8
commit
37151213cd
24 changed files with 643 additions and 380 deletions
|
@ -2,7 +2,7 @@
|
|||
version = "0.0.0"
|
||||
authors = ["Ankitects Pty Ltd and contributors <https://help.ankiweb.net>"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
rust-version = "1.64"
|
||||
rust-version = "1.65"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
|
|
@ -56,14 +56,90 @@ message GraphsRequest {
|
|||
}
|
||||
|
||||
message GraphsResponse {
|
||||
repeated cards.Card cards = 1;
|
||||
repeated RevlogEntry revlog = 2;
|
||||
uint32 days_elapsed = 3;
|
||||
// Based on rollover hour
|
||||
uint32 next_day_at_secs = 4;
|
||||
uint32 scheduler_version = 5;
|
||||
/// Seconds to add to UTC timestamps to get local time.
|
||||
int32 local_offset_secs = 7;
|
||||
message Added {
|
||||
map<int32, uint32> added = 1;
|
||||
}
|
||||
message Intervals {
|
||||
map<uint32, uint32> intervals = 1;
|
||||
}
|
||||
message Eases {
|
||||
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 {
|
||||
|
|
19
rslib/src/stats/graphs/added.rs
Normal file
19
rslib/src/stats/graphs/added.rs
Normal 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
|
||||
}
|
||||
}
|
89
rslib/src/stats/graphs/buttons.rs
Normal file
89
rslib/src/stats/graphs/buttons.rs
Normal 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
|
||||
}
|
||||
}
|
54
rslib/src/stats/graphs/card_counts.rs
Normal file
54
rslib/src/stats/graphs/card_counts.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
rslib/src/stats/graphs/eases.rs
Normal file
19
rslib/src/stats/graphs/eases.rs
Normal 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
|
||||
}
|
||||
}
|
39
rslib/src/stats/graphs/future_due.rs
Normal file
39
rslib/src/stats/graphs/future_due.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
61
rslib/src/stats/graphs/hours.rs
Normal file
61
rslib/src/stats/graphs/hours.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
19
rslib/src/stats/graphs/intervals.rs
Normal file
19
rslib/src/stats/graphs/intervals.rs
Normal 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
|
||||
}
|
||||
}
|
|
@ -1,6 +1,16 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// 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::{
|
||||
config::{BoolKey, Weekday},
|
||||
pb,
|
||||
|
@ -9,6 +19,14 @@ use crate::{
|
|||
search::SortMode,
|
||||
};
|
||||
|
||||
struct GraphsContext {
|
||||
revlog: Vec<RevlogEntry>,
|
||||
cards: Vec<Card>,
|
||||
next_day_start: TimestampSecs,
|
||||
days_elapsed: u32,
|
||||
local_offset_secs: i64,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub(crate) fn graph_data_for_search(
|
||||
&mut self,
|
||||
|
@ -29,26 +47,33 @@ impl Collection {
|
|||
} else {
|
||||
TimestampSecs(0)
|
||||
};
|
||||
|
||||
let offset = self.local_utc_offset_for_user()?;
|
||||
let local_offset_secs = offset.local_minus_utc() as i64;
|
||||
|
||||
let cards = self.storage.all_searched_cards()?;
|
||||
let revlog = if all {
|
||||
self.storage.get_all_revlog_entries(revlog_start)?
|
||||
} else {
|
||||
self.storage
|
||||
.get_pb_revlog_entries_for_searched_cards(revlog_start)?
|
||||
.get_revlog_entries_for_searched_cards_after_stamp(revlog_start)?
|
||||
};
|
||||
|
||||
Ok(pb::stats::GraphsResponse {
|
||||
cards: cards.into_iter().map(Into::into).collect(),
|
||||
let ctx = GraphsContext {
|
||||
revlog,
|
||||
days_elapsed: timing.days_elapsed,
|
||||
next_day_at_secs: timing.next_day_at.0 as u32,
|
||||
scheduler_version: self.scheduler_version() as u32,
|
||||
local_offset_secs: local_offset_secs as i32,
|
||||
})
|
||||
cards: self.storage.all_searched_cards()?,
|
||||
next_day_start: timing.next_day_at,
|
||||
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 {
|
||||
|
@ -79,19 +104,3 @@ impl Collection {
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
44
rslib/src/stats/graphs/reviews.rs
Normal file
44
rslib/src/stats/graphs/reviews.rs
Normal 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
|
||||
}
|
||||
}
|
44
rslib/src/stats/graphs/today.rs
Normal file
44
rslib/src/stats/graphs/today.rs
Normal 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
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@ use rusqlite::{
|
|||
use super::SqliteStorage;
|
||||
use crate::{
|
||||
error::Result,
|
||||
pb,
|
||||
prelude::*,
|
||||
revlog::{RevlogEntry, RevlogReviewKind},
|
||||
};
|
||||
|
@ -110,16 +109,16 @@ impl SqliteStorage {
|
|||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_pb_revlog_entries_for_searched_cards(
|
||||
pub(crate) fn get_revlog_entries_for_searched_cards_after_stamp(
|
||||
&self,
|
||||
after: TimestampSecs,
|
||||
) -> Result<Vec<pb::stats::RevlogEntry>> {
|
||||
) -> Result<Vec<RevlogEntry>> {
|
||||
self.db
|
||||
.prepare_cached(concat!(
|
||||
include_str!("get.sql"),
|
||||
" 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()
|
||||
}
|
||||
|
||||
|
@ -133,11 +132,7 @@ impl SqliteStorage {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// This includes entries from deleted cards.
|
||||
pub(crate) fn get_all_revlog_entries(
|
||||
&self,
|
||||
after: TimestampSecs,
|
||||
) -> Result<Vec<pb::stats::RevlogEntry>> {
|
||||
pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> {
|
||||
self.db
|
||||
.prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))?
|
||||
.query_and_then([after.0 * 1000], |r| row_to_revlog_entry(r).map(Into::into))?
|
||||
|
|
|
@ -6,25 +6,21 @@
|
|||
*/
|
||||
|
||||
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 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 { GraphRange } from "./graph-helpers";
|
||||
import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers";
|
||||
import type { HistogramData } from "./histogram-graph";
|
||||
|
||||
export interface GraphData {
|
||||
daysAdded: number[];
|
||||
daysAdded: Map<number, number>;
|
||||
}
|
||||
|
||||
export function gatherData(data: Stats.GraphsResponse): GraphData {
|
||||
const daysAdded = (data.cards as Cards.Card[]).map((card) => {
|
||||
const elapsedSecs = (card.id as number) / 1000 - data.nextDayAtSecs;
|
||||
return Math.ceil(elapsedSecs / 86400);
|
||||
});
|
||||
return { daysAdded };
|
||||
return { daysAdded: numericMap(data.added!.added) };
|
||||
}
|
||||
|
||||
function makeQuery(start: number, end: number): string {
|
||||
|
@ -45,13 +41,12 @@ export function buildHistogram(
|
|||
browserLinksSupported: boolean,
|
||||
): [HistogramData | null, TableDatum[]] {
|
||||
// get min/max
|
||||
const total = data.daysAdded.length;
|
||||
const total = data.daysAdded.size;
|
||||
if (!total) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
const [xMinOrig] = extent(data.daysAdded);
|
||||
let xMin = xMinOrig;
|
||||
let xMin: number;
|
||||
|
||||
// cap max to selected range
|
||||
switch (range) {
|
||||
|
@ -65,6 +60,7 @@ export function buildHistogram(
|
|||
xMin = -365;
|
||||
break;
|
||||
case GraphRange.AllTime:
|
||||
xMin = min(data.daysAdded.keys())!;
|
||||
break;
|
||||
}
|
||||
const xMax = 1;
|
||||
|
@ -72,8 +68,11 @@ export function buildHistogram(
|
|||
|
||||
const scale = scaleLinear().domain([xMin!, xMax]);
|
||||
const bins = bin()
|
||||
.value((m) => {
|
||||
return m[0];
|
||||
})
|
||||
.domain(scale.domain() as any)
|
||||
.thresholds(scale.ticks(desiredBars))(data.daysAdded);
|
||||
.thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
|
||||
|
||||
// empty graph?
|
||||
if (!sum(bins, (bin) => bin.length)) {
|
||||
|
@ -124,6 +123,7 @@ export function buildHistogram(
|
|||
hoverText,
|
||||
onClick: browserLinksSupported ? onClick : null,
|
||||
colourScale,
|
||||
binValue: getNumericMapBinValue,
|
||||
showArea: true,
|
||||
},
|
||||
tableData,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import { Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import {
|
||||
axisBottom,
|
||||
axisLeft,
|
||||
|
@ -20,11 +20,13 @@ import {
|
|||
sum,
|
||||
} from "d3";
|
||||
|
||||
import type { GraphBounds, GraphRange } from "./graph-helpers";
|
||||
import { millisecondCutoffForRange, setDataAvailable } from "./graph-helpers";
|
||||
import type { GraphBounds } from "./graph-helpers";
|
||||
import { GraphRange } from "./graph-helpers";
|
||||
import { setDataAvailable } from "./graph-helpers";
|
||||
import { hideTooltip, showTooltip } from "./tooltip";
|
||||
|
||||
type ButtonCounts = [number, number, number, number];
|
||||
/// 4 element array
|
||||
type ButtonCounts = number[];
|
||||
|
||||
export interface GraphData {
|
||||
learning: ButtonCounts;
|
||||
|
@ -32,48 +34,18 @@ export interface GraphData {
|
|||
mature: ButtonCounts;
|
||||
}
|
||||
|
||||
const ReviewKind = Stats.RevlogEntry.ReviewKind;
|
||||
|
||||
export function gatherData(data: Stats.GraphsResponse, range: GraphRange): GraphData {
|
||||
const cutoff = millisecondCutoffForRange(range, data.nextDayAtSecs);
|
||||
const learning: ButtonCounts = [0, 0, 0, 0];
|
||||
const young: ButtonCounts = [0, 0, 0, 0];
|
||||
const mature: ButtonCounts = [0, 0, 0, 0];
|
||||
|
||||
for (const review of data.revlog as Stats.RevlogEntry[]) {
|
||||
if (cutoff && (review.id as number) < cutoff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
const buttons = data.buttons!;
|
||||
switch (range) {
|
||||
case GraphRange.Month:
|
||||
return buttons.oneMonth!;
|
||||
case GraphRange.ThreeMonths:
|
||||
return buttons.threeMonths!;
|
||||
case GraphRange.Year:
|
||||
return buttons.oneYear!;
|
||||
case GraphRange.AllTime:
|
||||
return buttons.allTime!;
|
||||
}
|
||||
return { learning, young, mature };
|
||||
}
|
||||
|
||||
type GroupKind = "learning" | "young" | "mature";
|
||||
|
|
|
@ -48,19 +48,11 @@ export function gatherData(
|
|||
data: Stats.GraphsResponse,
|
||||
firstDayOfWeek: WeekdayType,
|
||||
): GraphData {
|
||||
const reviewCount = new Map<number, number>();
|
||||
|
||||
for (const review of data.revlog as Stats.RevlogEntry[]) {
|
||||
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 reviewCount = new Map(
|
||||
Object.entries(data.reviews!.count).map(([k, v]) => {
|
||||
return [Number(k), v.learn + v.relearn + v.mature + v.filtered + v.young];
|
||||
}),
|
||||
);
|
||||
const timeFunction = timeFunctionForDay(firstDayOfWeek);
|
||||
const weekdayLabels: number[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
|
|
|
@ -5,10 +5,9 @@
|
|||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import { CardQueue, CardType } from "@tslib/cards";
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import type { Cards, Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import {
|
||||
arc,
|
||||
cumsum,
|
||||
|
@ -20,6 +19,7 @@ import {
|
|||
schemeOranges,
|
||||
schemeReds,
|
||||
select,
|
||||
sum,
|
||||
} from "d3";
|
||||
|
||||
import type { GraphBounds } from "./graph-helpers";
|
||||
|
@ -41,83 +41,44 @@ const barColours = [
|
|||
"grey", /* buried */
|
||||
];
|
||||
|
||||
function countCards(cards: Cards.ICard[], separateInactive: boolean): Count[] {
|
||||
let newCards = 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
function countCards(data: Stats.GraphsResponse, separateInactive: boolean): Count[] {
|
||||
const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!;
|
||||
|
||||
const extraQuery = separateInactive ? "AND -(\"is:buried\" OR \"is:suspended\")" : "";
|
||||
|
||||
const counts: Count[] = [
|
||||
[tr.statisticsCountsNewCards(), newCards, true, `"is:new"${extraQuery}`],
|
||||
[tr.statisticsCountsNewCards(), countData.newCards, true, `"is:new"${extraQuery}`],
|
||||
[
|
||||
tr.statisticsCountsLearningCards(),
|
||||
learn,
|
||||
countData.learn,
|
||||
true,
|
||||
`(-"is:review" AND "is:learn")${extraQuery}`,
|
||||
],
|
||||
[
|
||||
tr.statisticsCountsRelearningCards(),
|
||||
relearn,
|
||||
countData.relearn,
|
||||
true,
|
||||
`("is:review" AND "is:learn")${extraQuery}`,
|
||||
],
|
||||
[
|
||||
tr.statisticsCountsYoungCards(),
|
||||
young,
|
||||
countData.young,
|
||||
true,
|
||||
`("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`,
|
||||
],
|
||||
[
|
||||
tr.statisticsCountsMatureCards(),
|
||||
mature,
|
||||
countData.mature,
|
||||
true,
|
||||
`("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`,
|
||||
],
|
||||
[
|
||||
tr.statisticsCountsSuspendedCards(),
|
||||
suspended,
|
||||
countData.suspended,
|
||||
separateInactive,
|
||||
"\"is:suspended\"",
|
||||
],
|
||||
[tr.statisticsCountsBuriedCards(), buried, separateInactive, "\"is:buried\""],
|
||||
[tr.statisticsCountsBuriedCards(), countData.buried, separateInactive, "\"is:buried\""],
|
||||
];
|
||||
|
||||
return counts;
|
||||
|
@ -127,8 +88,8 @@ export function gatherData(
|
|||
data: Stats.GraphsResponse,
|
||||
separateInactive: boolean,
|
||||
): GraphData {
|
||||
const totalCards = localizedNumber(data.cards.length);
|
||||
const counts = countCards(data.cards, separateInactive);
|
||||
const counts = countCards(data, separateInactive);
|
||||
const totalCards = localizedNumber(sum(counts, e => e[1]));
|
||||
|
||||
return {
|
||||
title: tr.statisticsCountsTitle(),
|
||||
|
|
|
@ -5,25 +5,22 @@
|
|||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import { CardType } from "@tslib/cards";
|
||||
import * as tr from "@tslib/ftl";
|
||||
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 { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3";
|
||||
|
||||
import type { SearchDispatch, TableDatum } from "./graph-helpers";
|
||||
import { getNumericMapBinValue, numericMap } from "./graph-helpers";
|
||||
import type { HistogramData } from "./histogram-graph";
|
||||
|
||||
export interface GraphData {
|
||||
eases: number[];
|
||||
eases: Map<number, number>;
|
||||
}
|
||||
|
||||
export function gatherData(data: Stats.GraphsResponse): GraphData {
|
||||
const eases = (data.cards as Cards.Card[])
|
||||
.filter((c) => [CardType.Review, CardType.Relearn].includes(c.ctype))
|
||||
.map((c) => c.easeFactor / 10);
|
||||
return { eases };
|
||||
return { eases: numericMap(data.eases!.eases) };
|
||||
}
|
||||
|
||||
function makeQuery(start: number, end: number): string {
|
||||
|
@ -68,11 +65,10 @@ export function prepareData(
|
|||
): [HistogramData | null, TableDatum[]] {
|
||||
// get min/max
|
||||
const allEases = data.eases;
|
||||
if (!allEases.length) {
|
||||
if (!allEases.size) {
|
||||
return [null, []];
|
||||
}
|
||||
const total = allEases.length;
|
||||
const [, origXMax] = extent(allEases);
|
||||
const [, origXMax] = extent(allEases.keys());
|
||||
const xMin = 130;
|
||||
const xMax = origXMax! + 1;
|
||||
const desiredBars = 20;
|
||||
|
@ -80,8 +76,12 @@ export function prepareData(
|
|||
const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);
|
||||
|
||||
const bins = bin()
|
||||
.value((m) => {
|
||||
return m[0];
|
||||
})
|
||||
.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]);
|
||||
|
||||
|
@ -90,7 +90,7 @@ export function prepareData(
|
|||
const maxPct = Math.floor(bin.x1!);
|
||||
const percent = maxPct - minPct <= 10 ? `${bin.x0}%` : `${bin.x0}%-${bin.x1}%`;
|
||||
return tr.statisticsCardEaseTooltip({
|
||||
cards: bin.length,
|
||||
cards: getNumericMapBinValue(bin as any),
|
||||
percent,
|
||||
});
|
||||
}
|
||||
|
@ -106,7 +106,7 @@ export function prepareData(
|
|||
const tableData = [
|
||||
{
|
||||
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,
|
||||
colourScale,
|
||||
showArea: false,
|
||||
binValue: getNumericMapBinValue,
|
||||
xTickFormat,
|
||||
},
|
||||
tableData,
|
||||
|
|
|
@ -5,16 +5,15 @@
|
|||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import { CardType } from "@tslib/cards";
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import type { Cards, Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import { dayLabel } from "@tslib/time";
|
||||
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 { GraphRange } from "./graph-helpers";
|
||||
import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers";
|
||||
import type { HistogramData } from "./histogram-graph";
|
||||
|
||||
export interface GraphData {
|
||||
|
@ -23,44 +22,8 @@ export interface GraphData {
|
|||
}
|
||||
|
||||
export function gatherData(data: Stats.GraphsResponse): GraphData {
|
||||
const isIntradayLearning = (card: Cards.Card, due: number): boolean => {
|
||||
return (
|
||||
[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]);
|
||||
const msg = data.futureDue!;
|
||||
return { dueCounts: numericMap(msg.futureDue), haveBacklog: msg.haveBacklog };
|
||||
}
|
||||
|
||||
export interface FutureDueResponse {
|
||||
|
@ -133,7 +96,7 @@ export function buildHistogram(
|
|||
const adjustedRange = scaleLinear().range([0.7, 0.3]);
|
||||
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(
|
||||
bin: Bin<number, number>,
|
||||
|
@ -142,7 +105,7 @@ export function buildHistogram(
|
|||
): string {
|
||||
const days = dayLabel(bin.x0!, bin.x1!);
|
||||
const cards = tr.statisticsCardsDue({
|
||||
cards: binValue(bin as any),
|
||||
cards: getNumericMapBinValue(bin as any),
|
||||
});
|
||||
const totalLabel = tr.statisticsRunningTotal();
|
||||
|
||||
|
@ -185,7 +148,7 @@ export function buildHistogram(
|
|||
onClick: browserLinksSupported ? onClick : null,
|
||||
showArea: true,
|
||||
colourScale,
|
||||
binValue,
|
||||
binValue: getNumericMapBinValue,
|
||||
xTickFormat,
|
||||
},
|
||||
tableData,
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
@typescript-eslint/ban-ts-comment: "off" */
|
||||
|
||||
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
|
||||
export enum RevlogRange {
|
||||
|
@ -97,3 +98,13 @@ export type SearchDispatch = <EventKey extends Extract<keyof SearchEventMap, str
|
|||
type: EventKey,
|
||||
detail: SearchEventMap[EventKey],
|
||||
) => 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]);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import { Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import {
|
||||
area,
|
||||
axisBottom,
|
||||
|
@ -22,8 +22,8 @@ import {
|
|||
select,
|
||||
} from "d3";
|
||||
|
||||
import type { GraphBounds, GraphRange } from "./graph-helpers";
|
||||
import { millisecondCutoffForRange, setDataAvailable } from "./graph-helpers";
|
||||
import type { GraphBounds } from "./graph-helpers";
|
||||
import { GraphRange, setDataAvailable } from "./graph-helpers";
|
||||
import { oddTickClass } from "./graph-styles";
|
||||
import { hideTooltip, showTooltip } from "./tooltip";
|
||||
|
||||
|
@ -33,38 +33,22 @@ interface Hour {
|
|||
correctCount: number;
|
||||
}
|
||||
|
||||
const ReviewKind = Stats.RevlogEntry.ReviewKind;
|
||||
|
||||
function gatherData(data: Stats.GraphsResponse, range: GraphRange): Hour[] {
|
||||
const hours = [...Array(24)].map((_n, idx: number) => {
|
||||
return { hour: idx, totalCount: 0, correctCount: 0 } as Hour;
|
||||
});
|
||||
const cutoff = millisecondCutoffForRange(range, data.nextDayAtSecs);
|
||||
|
||||
for (const review of data.revlog as Stats.RevlogEntry[]) {
|
||||
switch (review.reviewKind) {
|
||||
case ReviewKind.LEARNING:
|
||||
case ReviewKind.REVIEW:
|
||||
case ReviewKind.RELEARNING:
|
||||
break; // from switch
|
||||
case ReviewKind.FILTERED:
|
||||
case ReviewKind.MANUAL:
|
||||
continue; // next loop iteration
|
||||
}
|
||||
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;
|
||||
}
|
||||
function convert(hours: Stats.GraphsResponse.Hours.IHour[]): Hour[] {
|
||||
return hours.map((e, idx) => {
|
||||
return { hour: idx, totalCount: e.total!, correctCount: e.correct! };
|
||||
});
|
||||
}
|
||||
switch (range) {
|
||||
case GraphRange.Month:
|
||||
return convert(data.hours!.oneMonth);
|
||||
case GraphRange.ThreeMonths:
|
||||
return convert(data.hours!.threeMonths);
|
||||
case GraphRange.Year:
|
||||
return convert(data.hours!.oneYear);
|
||||
case GraphRange.AllTime:
|
||||
return convert(data.hours!.allTime);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function renderHours(
|
||||
|
|
|
@ -5,15 +5,15 @@
|
|||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
import { CardType } from "@tslib/cards";
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import type { Cards, Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import { timeSpan } from "@tslib/time";
|
||||
import type { Bin } from "d3";
|
||||
import { bin, extent, interpolateBlues, mean, quantile, scaleLinear, scaleSequential, sum } from "d3";
|
||||
|
||||
import type { SearchDispatch, TableDatum } from "./graph-helpers";
|
||||
import { numericMap } from "./graph-helpers";
|
||||
import type { HistogramData } from "./histogram-graph";
|
||||
|
||||
export interface IntervalGraphData {
|
||||
|
@ -28,10 +28,19 @@ export enum IntervalRange {
|
|||
}
|
||||
|
||||
export function gatherIntervalData(data: Stats.GraphsResponse): IntervalGraphData {
|
||||
const intervals = (data.cards as Cards.Card[])
|
||||
.filter((c) => [CardType.Review, CardType.Relearn].includes(c.ctype))
|
||||
.map((c) => c.interval);
|
||||
return { intervals };
|
||||
// This could be made more efficient - this graph currently expects a flat list of individual intervals which it
|
||||
// uses to calculate a percentile and then converts into a histogram, and the percentile/histogram calculations
|
||||
// in JS are relatively slow.
|
||||
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(
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import { Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import { dayLabel, timeSpan } from "@tslib/time";
|
||||
import type { Bin, ScaleSequential } from "d3";
|
||||
import {
|
||||
|
@ -32,7 +32,7 @@ import {
|
|||
} from "d3";
|
||||
|
||||
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";
|
||||
|
||||
interface Reviews {
|
||||
|
@ -49,51 +49,10 @@ export interface GraphData {
|
|||
reviewTime: Map<number, Reviews>;
|
||||
}
|
||||
|
||||
const ReviewKind = Stats.RevlogEntry.ReviewKind;
|
||||
type BinType = Bin<Map<number, Reviews[]>, number>;
|
||||
|
||||
export function gatherData(data: Stats.GraphsResponse): GraphData {
|
||||
const reviewCount = new Map<number, Reviews>();
|
||||
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 };
|
||||
return { reviewCount: numericMap(data.reviews!.count), reviewTime: numericMap(data.reviews!.time) };
|
||||
}
|
||||
|
||||
enum BinIndex {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import * as tr from "@tslib/ftl";
|
||||
import { localizedNumber } from "@tslib/i18n";
|
||||
import { Stats } from "@tslib/proto";
|
||||
import type { Stats } from "@tslib/proto";
|
||||
import { studiedToday } from "@tslib/time";
|
||||
|
||||
export interface TodayData {
|
||||
|
@ -11,91 +11,34 @@ export interface TodayData {
|
|||
lines: string[];
|
||||
}
|
||||
|
||||
const ReviewKind = Stats.RevlogEntry.ReviewKind;
|
||||
|
||||
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[];
|
||||
if (answerCount) {
|
||||
const studiedTodayText = studiedToday(answerCount, answerMillis / 1000);
|
||||
const againCount = answerCount - correctCount;
|
||||
const today = data.today!;
|
||||
if (today.answerCount) {
|
||||
const studiedTodayText = studiedToday(today.answerCount, today.answerMillis / 1000);
|
||||
const againCount = today.answerCount - today.correctCount;
|
||||
let againCountText = tr.statisticsTodayAgainCount();
|
||||
againCountText += ` ${againCount} (${
|
||||
localizedNumber(
|
||||
(againCount / answerCount) * 100,
|
||||
(againCount / today.answerCount) * 100,
|
||||
)
|
||||
}%)`;
|
||||
const typeCounts = tr.statisticsTodayTypeCounts({
|
||||
learnCount,
|
||||
reviewCount,
|
||||
relearnCount,
|
||||
filteredCount: earlyReviewCount,
|
||||
learnCount: today.learnCount,
|
||||
reviewCount: today.reviewCount,
|
||||
relearnCount: today.relearnCount,
|
||||
filteredCount: today.earlyReviewCount,
|
||||
});
|
||||
let matureText: string;
|
||||
if (matureCount) {
|
||||
if (today.matureCount) {
|
||||
matureText = tr.statisticsTodayCorrectMature({
|
||||
correct: matureCorrect,
|
||||
total: matureCount,
|
||||
percent: (matureCorrect / matureCount) * 100,
|
||||
correct: today.matureCorrect,
|
||||
total: today.matureCount,
|
||||
percent: (today.matureCorrect / today.matureCount) * 100,
|
||||
});
|
||||
} else {
|
||||
matureText = tr.statisticsTodayNoMatureCards();
|
||||
}
|
||||
|
||||
lines = [studiedTodayText, againCountText, typeCounts, matureText];
|
||||
} else {
|
||||
lines = [tr.statisticsTodayNoCards()];
|
||||
|
|
Loading…
Reference in a new issue