Add last_review_time to card data

This commit is contained in:
Jarrett Ye 2025-06-24 17:49:25 +08:00
parent b250a2f724
commit 91e729be1e
No known key found for this signature in database
GPG key ID: EBFC55E0C1A352BB
11 changed files with 68 additions and 29 deletions

View file

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

View file

@ -128,17 +128,22 @@ 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<u32> {
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,
)
if let Some(last_review_time) = self.last_review_time {
Some(timing.next_day_at.elapsed_days_since(last_review_time) as u32)
} else {
self.due_time(timing).map(|due| {
(due.adding_secs(-86_400 * self.interval as i64)
.elapsed_secs()
/ 86_400) as u32
})
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,
)
} else {
self.due_time(timing).map(|due| {
(due.adding_secs(-86_400 * self.interval as i64)
.elapsed_secs()
/ 86_400) as u32
})
}
}
}
}

View file

@ -96,6 +96,7 @@ pub struct Card {
pub(crate) memory_state: Option<FsrsMemoryState>,
pub(crate) desired_retention: Option<f32>,
pub(crate) decay: Option<f32>,
pub(crate) last_review_time: Option<TimestampSecs>,
/// 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(),
}
}

View file

@ -107,6 +107,7 @@ impl TryFrom<anki_proto::cards::Card> 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<Card> 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,
}
}

View file

@ -334,6 +334,7 @@ impl Collection {
self.maybe_bury_siblings(&original, &updater.config)?;
let timing = updater.timing;
let mut card = updater.into_card();
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()?;
@ -448,11 +449,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,

View file

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

View file

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

View file

@ -47,6 +47,12 @@ pub(crate) struct CardData {
deserialize_with = "default_on_invalid"
)]
pub(crate) decay: Option<f32>,
#[serde(
rename = "lrt",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) last_review_time: Option<TimestampSecs>,
/// 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!(

View file

@ -97,6 +97,7 @@ fn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {
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,
})
}

View file

@ -314,7 +314,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);

View file

@ -333,6 +333,7 @@ impl From<CardEntry> 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,
}
}