log manual reschedule, but ignore the log entry in the stats

This commit is contained in:
Damien Elmes 2020-09-02 17:18:29 +10:00
parent 39212a38aa
commit ce49ca9401
21 changed files with 249 additions and 82 deletions

View file

@ -95,7 +95,8 @@ service BackendService {
rpc LocalMinutesWest (Int64) returns (Int32);
rpc SetLocalMinutesWest (Int32) returns (Empty);
rpc SchedTimingToday (Empty) returns (SchedTimingTodayOut);
rpc StudiedToday (StudiedTodayIn) returns (String);
rpc StudiedToday (Empty) returns (String);
rpc StudiedTodayMessage (StudiedTodayMessageIn) returns (String);
rpc UpdateStats (UpdateStatsIn) returns (Empty);
rpc ExtendLimits (ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut);
@ -685,7 +686,7 @@ message FormatTimespanIn {
Context context = 2;
}
message StudiedTodayIn {
message StudiedTodayMessageIn {
uint32 cards = 1;
double seconds = 2;
}
@ -1016,6 +1017,7 @@ message RevlogEntry {
REVIEW = 1;
RELEARNING = 2;
EARLY_REVIEW = 3;
MANUAL = 4;
}
int64 id = 1;
int64 cid = 2;

View file

@ -516,6 +516,9 @@ table.review-log {{ {revlog_style} }}
return style + self.backend.card_stats(card_id)
def studied_today(self) -> str:
return self.backend.studied_today()
# legacy
def cardStats(self, card: Card) -> str:

View file

@ -145,7 +145,9 @@ from revlog where id > ? """
return "<b>" + str(s) + "</b>"
if cards:
b += self.col.backend.studied_today(cards=cards, seconds=float(thetime))
b += self.col.backend.studied_today_message(
cards=cards, seconds=float(thetime)
)
# again/pass count
b += "<br>" + _("Again count: %s") % bold(failed)
if cards:

View file

@ -138,16 +138,7 @@ class DeckBrowser:
self.web.eval("$(function() { window.scrollTo(0, %d, 'instant'); });" % offset)
def _renderStats(self):
cards, thetime = self.mw.col.db.first(
"""
select count(), sum(time)/1000 from revlog
where id > ?""",
(self.mw.col.sched.dayCutoff - 86400) * 1000,
)
cards = cards or 0
thetime = thetime or 0
buf = self.mw.col.backend.studied_today(cards=cards, seconds=float(thetime))
return buf
return self.mw.col.studied_today()
def _renderDeckTree(self, top: DeckTreeNode) -> str:
buf = """

View file

@ -21,3 +21,5 @@ card-stats-review-log-type-learn = Learn
card-stats-review-log-type-review = Review
card-stats-review-log-type-relearn = Relearn
card-stats-review-log-type-filtered = Filtered
card-stats-review-log-type-manual = Manual

View file

@ -31,8 +31,9 @@ use crate::{
RenderCardOutput,
},
sched::cutoff::local_minutes_west_for_stamp,
sched::timespan::{answer_button_time, studied_today, time_span},
sched::timespan::{answer_button_time, time_span},
search::SortMode,
stats::studied_today,
sync::{
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage,
@ -472,8 +473,17 @@ impl BackendService for Backend {
})
}
fn studied_today(&mut self, input: pb::StudiedTodayIn) -> BackendResult<pb::String> {
Ok(studied_today(input.cards as usize, input.seconds as f32, &self.i18n).into())
/// Fetch data from DB and return rendered string.
fn studied_today(&mut self, _input: pb::Empty) -> BackendResult<pb::String> {
self.with_col(|col| col.studied_today().map(Into::into))
}
/// Message rendering only, for old graphs.
fn studied_today_message(
&mut self,
input: pb::StudiedTodayMessageIn,
) -> BackendResult<pb::String> {
Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into())
}
fn update_stats(&mut self, input: pb::UpdateStatsIn) -> BackendResult<Empty> {

View file

@ -6,8 +6,8 @@ use crate::define_newtype;
use crate::err::{AnkiError, Result};
use crate::notes::NoteID;
use crate::{
collection::Collection, config::SchedulerVersion, deckconf::INITIAL_EASE_FACTOR,
timestamp::TimestampSecs, types::Usn, undo::Undoable,
collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn,
undo::Undoable,
};
use num_enum::TryFromPrimitive;
use serde_repr::{Deserialize_repr, Serialize_repr};
@ -200,32 +200,6 @@ impl Card {
self.original_due = 0;
}
/// Remove the card from the (re)learning queue.
/// This will reset cards in learning.
/// Only used in the V1 scheduler.
/// Unlike the legacy Python code, this sets the due# to 0 instead of
/// one past the previous max due number.
pub(crate) fn remove_from_learning(&mut self) {
if !matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn) {
return;
}
if self.ctype == CardType::Review {
// reviews are removed from relearning
self.due = self.original_due;
self.original_due = 0;
self.queue = CardQueue::Review;
} else {
// other cards are reset to new
self.ctype = CardType::New;
self.queue = CardQueue::New;
self.interval = 0;
self.due = 0;
self.original_due = 0;
self.ease_factor = INITIAL_EASE_FACTOR;
}
}
}
#[derive(Debug)]
pub(crate) struct UpdateCardUndo(Card);

View file

@ -42,6 +42,7 @@ pub enum RevlogReviewKind {
Review = 1,
Relearning = 2,
EarlyReview = 3,
Manual = 4,
}
impl Default for RevlogReviewKind {
@ -59,3 +60,40 @@ impl RevlogEntry {
}) as u32
}
}
impl Card {
fn last_interval_for_revlog_todo(&self) -> i32 {
self.interval as i32
// fixme: need to pass in delays for (re)learning
// if let Some(delay) = self.current_learning_delay_seconds(&[]) {
// -(delay as i32)
// } else {
// self.interval as i32
// }
}
}
impl Collection {
pub(crate) fn log_manually_scheduled_review(
&mut self,
card: &Card,
usn: Usn,
next_interval: u32,
) -> Result<()> {
println!("fixme: learning last_interval");
// let deck = self.get_deck(card.deck_id)?.ok_or(AnkiError::NotFound)?;
let entry = RevlogEntry {
id: TimestampMillis::now(),
cid: card.id,
usn,
button_chosen: 0,
interval: next_interval as i32,
last_interval: card.last_interval_for_revlog_todo(),
ease_factor: card.ease_factor as u32,
taken_millis: 0,
review_kind: RevlogReviewKind::Manual,
};
self.storage.add_revlog_entry(&entry)
}
}

View file

@ -0,0 +1,58 @@
// 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},
deckconf::INITIAL_EASE_FACTOR,
};
impl Card {
/// Remove the card from the (re)learning queue.
/// This will reset cards in learning.
/// Only used in the V1 scheduler.
/// Unlike the legacy Python code, this sets the due# to 0 instead of
/// one past the previous max due number.
pub(crate) fn remove_from_learning(&mut self) {
if !matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn) {
return;
}
if self.ctype == CardType::Review {
// reviews are removed from relearning
self.due = self.original_due;
self.original_due = 0;
self.queue = CardQueue::Review;
} else {
// other cards are reset to new
self.ctype = CardType::New;
self.queue = CardQueue::New;
self.interval = 0;
self.due = 0;
self.original_due = 0;
self.ease_factor = INITIAL_EASE_FACTOR;
}
}
fn all_remaining_steps(&self) -> u32 {
self.remaining_steps % 1000
}
#[allow(dead_code)]
fn remaining_steps_today(&self) -> u32 {
self.remaining_steps / 1000
}
#[allow(dead_code)]
pub(crate) fn current_learning_delay_seconds(&self, delays: &[u32]) -> Option<u32> {
if self.queue == CardQueue::Learn {
let remaining = self.all_remaining_steps();
delays
.iter()
.nth_back(remaining.saturating_sub(0) as usize)
.or(Some(&0))
.map(|n| n * 60)
} else {
None
}
}
}

View file

@ -8,6 +8,7 @@ use crate::{
pub mod bury_and_suspend;
pub(crate) mod congrats;
pub mod cutoff;
mod learning;
mod reviews;
pub mod timespan;

View file

@ -40,6 +40,7 @@ impl Collection {
for mut card in col.storage.all_searched_cards()? {
let original = card.clone();
let interval = distribution.sample(&mut rng);
col.log_manually_scheduled_review(&card, usn, interval)?;
card.schedule_as_review(interval, today);
col.update_card(&mut card, &original, usn)?;
}

View file

@ -41,21 +41,6 @@ pub fn time_span(seconds: f32, i18n: &I18n, precise: bool) -> String {
i18n.trn(key, args)
}
// fixme: this doesn't belong here
pub fn studied_today(cards: usize, secs: f32, i18n: &I18n) -> String {
let span = Timespan::from_secs(secs).natural_span();
let amount = span.as_unit();
let unit = span.unit().as_str();
let secs_per = if cards > 0 {
secs / (cards as f32)
} else {
0.0
};
let args = tr_args!["amount" => amount, "unit" => unit,
"cards" => cards, "secs-per-card" => secs_per];
i18n.trn(TR::StatisticsStudiedToday, args)
}
const SECOND: f32 = 1.0;
const MINUTE: f32 = 60.0 * SECOND;
const HOUR: f32 = 60.0 * MINUTE;
@ -64,7 +49,7 @@ const MONTH: f32 = 30.0 * DAY;
const YEAR: f32 = 12.0 * MONTH;
#[derive(Clone, Copy)]
enum TimespanUnit {
pub(crate) enum TimespanUnit {
Seconds,
Minutes,
Hours,
@ -74,7 +59,7 @@ enum TimespanUnit {
}
impl TimespanUnit {
fn as_str(self) -> &'static str {
pub fn as_str(self) -> &'static str {
match self {
TimespanUnit::Seconds => "seconds",
TimespanUnit::Minutes => "minutes",
@ -87,13 +72,13 @@ impl TimespanUnit {
}
#[derive(Clone, Copy)]
struct Timespan {
pub(crate) struct Timespan {
seconds: f32,
unit: TimespanUnit,
}
impl Timespan {
fn from_secs(seconds: f32) -> Self {
pub fn from_secs(seconds: f32) -> Self {
Timespan {
seconds,
unit: TimespanUnit::Seconds,
@ -102,7 +87,7 @@ impl Timespan {
/// Return the value as the configured unit, eg seconds=70/unit=Minutes
/// returns 1.17
fn as_unit(self) -> f32 {
pub fn as_unit(self) -> f32 {
let s = self.seconds;
match self.unit {
TimespanUnit::Seconds => s,
@ -116,7 +101,7 @@ impl Timespan {
/// Round seconds and days to integers, otherwise
/// truncates to one decimal place.
fn as_rounded_unit(self) -> f32 {
pub fn as_rounded_unit(self) -> f32 {
match self.unit {
// seconds/days as integer
TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(),
@ -125,13 +110,13 @@ impl Timespan {
}
}
fn unit(self) -> TimespanUnit {
pub fn unit(self) -> TimespanUnit {
self.unit
}
/// Return a new timespan in the most appropriate unit, eg
/// 70 secs -> timespan in minutes
fn natural_span(self) -> Timespan {
pub fn natural_span(self) -> Timespan {
let secs = self.seconds.abs();
let unit = if secs < MINUTE {
TimespanUnit::Seconds
@ -158,7 +143,7 @@ impl Timespan {
mod test {
use crate::i18n::I18n;
use crate::log;
use crate::sched::timespan::{answer_button_time, studied_today, time_span, MONTH};
use crate::sched::timespan::{answer_button_time, time_span, MONTH};
#[test]
fn answer_buttons() {
@ -180,15 +165,4 @@ mod test {
assert_eq!(time_span(45.0 * 86_400.0, &i18n, false), "1.5 months");
assert_eq!(time_span(365.0 * 86_400.0 * 1.5, &i18n, false), "1.5 years");
}
#[test]
fn combo() {
// temporary test of fluent term handling
let log = log::terminal();
let i18n = I18n::new(&["zz"], "", log);
assert_eq!(
&studied_today(3, 13.0, &i18n).replace("\n", " "),
"Studied 3 cards in 13 seconds today (4.33s/card)"
);
}
}

View file

@ -229,12 +229,14 @@ fn revlog_to_text(e: RevlogEntry, i18n: &I18n, offset: FixedOffset) -> RevlogTex
RevlogReviewKind::Review => i18n.tr(TR::CardStatsReviewLogTypeReview).into(),
RevlogReviewKind::Relearning => i18n.tr(TR::CardStatsReviewLogTypeRelearn).into(),
RevlogReviewKind::EarlyReview => i18n.tr(TR::CardStatsReviewLogTypeFiltered).into(),
RevlogReviewKind::Manual => i18n.tr(TR::CardStatsReviewLogTypeManual).into(),
};
let kind_class = match e.review_kind {
RevlogReviewKind::Learning => String::from("revlog-learn"),
RevlogReviewKind::Review => String::from("revlog-review"),
RevlogReviewKind::Relearning => String::from("revlog-relearn"),
RevlogReviewKind::EarlyReview => String::from("revlog-filtered"),
RevlogReviewKind::Manual => String::from("revlog-manual"),
};
let rating = e.button_chosen.to_string();
let interval = if e.interval == 0 {

View file

@ -3,3 +3,6 @@
mod card;
mod graphs;
mod today;
pub use today::studied_today;

45
rslib/src/stats/today.rs Normal file
View file

@ -0,0 +1,45 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{i18n::I18n, prelude::*, sched::timespan::Timespan};
pub fn studied_today(cards: u32, secs: f32, i18n: &I18n) -> String {
let span = Timespan::from_secs(secs).natural_span();
let amount = span.as_unit();
let unit = span.unit().as_str();
let secs_per = if cards > 0 {
secs / (cards as f32)
} else {
0.0
};
let args = tr_args!["amount" => amount, "unit" => unit,
"cards" => cards, "secs-per-card" => secs_per];
i18n.trn(TR::StatisticsStudiedToday, args)
}
impl Collection {
pub fn studied_today(&self) -> Result<String> {
let today = self
.storage
.studied_today(self.timing_today()?.next_day_at)?;
Ok(studied_today(today.cards, today.seconds as f32, &self.i18n))
}
}
#[cfg(test)]
mod test {
use super::studied_today;
use crate::i18n::I18n;
use crate::log;
#[test]
fn today() {
// temporary test of fluent term handling
let log = log::terminal();
let i18n = I18n::new(&["zz"], "", log);
assert_eq!(
&studied_today(3, 13.0, &i18n).replace("\n", " "),
"Studied 3 cards in 13 seconds today (4.33s/card)"
);
}
}

View file

@ -10,5 +10,25 @@ insert
time,
type
)
values
(?, ?, ?, ?, ?, ?, ?, ?, ?)
values (
(
case
when ?1 in (
select id
from revlog
) then (
select max(id) + 1
from revlog
)
else ?1
end
),
?,
?,
?,
?,
?,
?,
?,
?
)

View file

@ -15,6 +15,11 @@ use rusqlite::{
};
use std::convert::TryFrom;
pub(crate) struct StudiedToday {
pub cards: u32,
pub seconds: f64,
}
impl FromSql for RevlogReviewKind {
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
if let ValueRef::Integer(i) = value {
@ -113,4 +118,19 @@ impl SqliteStorage {
})?
.collect()
}
pub(crate) fn studied_today(&self, day_cutoff: i64) -> Result<StudiedToday> {
let start = (day_cutoff - 86_400) * 1_000;
self.db
.prepare_cached(include_str!("studied_today.sql"))?
.query_map(&[start, RevlogReviewKind::Manual as i64], |row| {
Ok(StudiedToday {
cards: row.get(0)?,
seconds: row.get(1)?,
})
})?
.next()
.unwrap()
.map_err(Into::into)
}
}

View file

@ -0,0 +1,5 @@
select count(),
coalesce(sum(time) / 1000.0, 0.0)
from revlog
where id > ?
and type != ?

View file

@ -15,6 +15,10 @@ impl TimestampSecs {
Self(elapsed().as_secs() as i64)
}
pub fn zero() -> Self {
Self(0)
}
pub fn elapsed_secs(self) -> u64 {
(Self::now().0 - self.0).max(0) as u64
}
@ -30,6 +34,10 @@ impl TimestampMillis {
Self(elapsed().as_millis() as i64)
}
pub fn zero() -> Self {
Self(0)
}
pub fn as_secs(self) -> TimestampSecs {
TimestampSecs(self.0 / 1000)
}

View file

@ -47,6 +47,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut): GraphData {
const empty = { mature: 0, young: 0, learn: 0, relearn: 0, early: 0 };
for (const review of data.revlog as pb.BackendProto.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
);

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import pb from "../backend/proto";
import pb, { BackendProto } from "../backend/proto";
import { studiedToday } from "../time";
import { I18n } from "../i18n";
@ -30,6 +30,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut, i18n: I18n): TodayDa
continue;
}
if (review.reviewKind == ReviewKind.MANUAL) {
continue;
}
// total
answerCount += 1;
answerMillis += review.takenMillis;