diff --git a/proto/anki/cards.proto b/proto/anki/cards.proto index c120440e8..5c9838571 100644 --- a/proto/anki/cards.proto +++ b/proto/anki/cards.proto @@ -51,6 +51,7 @@ message Card { optional FsrsMemoryState memory_state = 20; optional float desired_retention = 21; optional float decay = 22; + optional int64 last_review_time_secs = 23; string custom_data = 19; } diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index 02807ae73..854d4ed18 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -49,6 +49,7 @@ class Card(DeprecatedNamesMixin): memory_state: FSRSMemoryState | None desired_retention: float | None decay: float | None + last_review_time: int | None def __init__( self, @@ -103,6 +104,11 @@ class Card(DeprecatedNamesMixin): card.desired_retention if card.HasField("desired_retention") else None ) self.decay = card.decay if card.HasField("decay") else None + self.last_review_time = ( + card.last_review_time_secs + if card.HasField("last_review_time_secs") + else None + ) def _to_backend_card(self) -> cards_pb2.Card: # mtime & usn are set by backend diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index 85b0572d3..c297f2bac 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -128,7 +128,9 @@ impl Card { /// This uses card.due and card.ivl to infer the elapsed time. If 'set due /// date' or an add-on has changed the due date, this won't be accurate. pub(crate) fn days_since_last_review(&self, timing: &SchedTimingToday) -> Option { - if !self.is_due_in_days() { + if let Some(last_review_time) = self.last_review_time { + Some(timing.next_day_at.elapsed_days_since(last_review_time) as u32) + } else if !self.is_due_in_days() { Some( (timing.next_day_at.0 as u32).saturating_sub(self.original_or_current_due() as u32) / 86_400, diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 598ac602b..b6b9ce807 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -96,6 +96,7 @@ pub struct Card { pub(crate) memory_state: Option, pub(crate) desired_retention: Option, pub(crate) decay: Option, + pub(crate) last_review_time: Option, /// JSON object or empty; exposed through the reviewer for persisting custom /// state pub(crate) custom_data: String, @@ -147,6 +148,7 @@ impl Default for Card { memory_state: None, desired_retention: None, decay: None, + last_review_time: None, custom_data: String::new(), } } diff --git a/rslib/src/card/service.rs b/rslib/src/card/service.rs index 8f1421f25..cc3fc6b05 100644 --- a/rslib/src/card/service.rs +++ b/rslib/src/card/service.rs @@ -107,6 +107,7 @@ impl TryFrom for Card { memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, + last_review_time: c.last_review_time_secs.map(TimestampSecs), custom_data: c.custom_data, }) } @@ -136,6 +137,7 @@ impl From for anki_proto::cards::Card { memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, + last_review_time_secs: c.last_review_time.map(|t| t.0), custom_data: c.custom_data, } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index ce6720d3d..eab89b783 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -335,6 +335,12 @@ impl Collection { self.maybe_bury_siblings(&original, &updater.config)?; let timing = updater.timing; let mut card = updater.into_card(); + if !matches!( + answer.current_state, + CardState::Filtered(FilteredState::Preview(_)) + ) { + card.last_review_time = Some(answer.answered_at.as_secs()); + } if let Some(data) = answer.custom_data.take() { card.custom_data = data; card.validate_custom_data()?; @@ -451,11 +457,14 @@ impl Collection { )?; card.set_memory_state(&fsrs, item, config.inner.historical_retention)?; } - let days_elapsed = self - .storage - .time_of_last_review(card.id)? - .map(|ts| timing.next_day_at.elapsed_days_since(ts)) - .unwrap_or_default() as u32; + let days_elapsed = if let Some(last_review_time) = card.last_review_time { + timing.next_day_at.elapsed_days_since(last_review_time) as u32 + } else { + self.storage + .time_of_last_review(card.id)? + .map(|ts| timing.next_day_at.elapsed_days_since(ts)) + .unwrap_or_default() as u32 + }; Some(fsrs.next_states( card.memory_state.map(Into::into), config.inner.desired_retention, diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index 06390e57d..f8d433f42 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -36,15 +36,21 @@ impl Card { let new_due = (today + days_from_today) as i32; let fsrs_enabled = self.memory_state.is_some(); let new_interval = if fsrs_enabled { - let due = self.original_or_current_due(); - let due_diff = if is_unix_epoch_timestamp(due) { - let offset = (due as i64 - next_day_start) / 86_400; - let due = (today as i64 + offset) as i32; - new_due - due + if let Some(last_review_time) = self.last_review_time { + let elapsed_days = + TimestampSecs(next_day_start).elapsed_days_since(last_review_time); + elapsed_days as u32 + days_from_today } else { - new_due - due - }; - self.interval.saturating_add_signed(due_diff) + let due = self.original_or_current_due(); + let due_diff = if is_unix_epoch_timestamp(due) { + let offset = (due as i64 - next_day_start) / 86_400; + let due = (today as i64 + offset) as i32; + new_due - due + } else { + new_due - due + }; + self.interval.saturating_add_signed(due_diff) + } } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { days_from_today.max(1) } else { diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index b04539717..fdab209c8 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,11 +30,14 @@ impl Collection { let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let timing = self.timing_today()?; - let seconds_elapsed = self - .storage - .time_of_last_review(card.id)? - .map(|ts| timing.now.elapsed_secs_since(ts)) - .unwrap_or_default() as u32; + let seconds_elapsed = if let Some(last_review_time) = card.last_review_time { + timing.now.elapsed_secs_since(last_review_time) as u32 + } else { + self.storage + .time_of_last_review(card.id)? + .map(|ts| timing.now.elapsed_secs_since(ts)) + .unwrap_or_default() as u32 + }; let fsrs_retrievability = card .memory_state .zip(Some(seconds_elapsed)) diff --git a/rslib/src/storage/card/data.rs b/rslib/src/storage/card/data.rs index 6545a6c60..aeee0fbb5 100644 --- a/rslib/src/storage/card/data.rs +++ b/rslib/src/storage/card/data.rs @@ -47,6 +47,12 @@ pub(crate) struct CardData { deserialize_with = "default_on_invalid" )] pub(crate) decay: Option, + #[serde( + rename = "lrt", + skip_serializing_if = "Option::is_none", + deserialize_with = "default_on_invalid" + )] + pub(crate) last_review_time: Option, /// A string representation of a JSON object storing optional data /// associated with the card, so v3 custom scheduling code can persist @@ -63,6 +69,7 @@ impl CardData { fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty), fsrs_desired_retention: card.desired_retention, decay: card.decay, + last_review_time: card.last_review_time, custom_data: card.custom_data.clone(), } } @@ -169,6 +176,7 @@ mod test { fsrs_difficulty: Some(1.234567), fsrs_desired_retention: Some(0.987654), decay: Some(0.123456), + last_review_time: None, custom_data: "".to_string(), }; assert_eq!( diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 0205aef0d..35a229e93 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -97,6 +97,7 @@ fn row_to_card(row: &Row) -> result::Result { memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, + last_review_time: data.last_review_time, custom_data: data.custom_data, }) } diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 34e2a85d1..e4b6f60f0 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -328,7 +328,13 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> { let Ok(due) = ctx.get_raw(1).as_i64() else { return Ok(None); }; - let days_elapsed = if due > 365_000 { + let days_elapsed = if let Some(last_review_time) = card_data.last_review_time { + // Use last_review_time to calculate days_elapsed + let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { + return Ok(None); + }; + (next_day_at as u32).saturating_sub(last_review_time.0 as u32) / 86_400 + } else if due > 365_000 { // (re)learning card in seconds let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { return Ok(None); @@ -396,6 +402,14 @@ fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result desired_retrievability = desired_retrievability.max(0.0001); let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); + let days_elapsed = if let Some(last_review_time) = + card_data.last_review_time + { + TimestampSecs(next_day_at).elapsed_days_since(last_review_time) as u32 + } else { + days_elapsed + }; + let current_retrievability = FSRS::new(None) .unwrap() .current_retrievability(state.into(), days_elapsed, decay) diff --git a/rslib/src/sync/collection/chunks.rs b/rslib/src/sync/collection/chunks.rs index 9d74ddb6c..7873c89c1 100644 --- a/rslib/src/sync/collection/chunks.rs +++ b/rslib/src/sync/collection/chunks.rs @@ -333,6 +333,7 @@ impl From for Card { memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, + last_review_time: data.last_review_time, custom_data: data.custom_data, } }