From 59e143ec25f0b317b46942f0a8c5a6bd3a4ec1ca Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Tue, 18 Feb 2025 14:44:00 +0800 Subject: [PATCH] Feat/support load balance and easy days in rescheduling (#3815) * Feat/support load balance and easy days in rescheduling * ./ninja fix:minilints * apply clippy * reuse calculate_easy_days_modifiers() * consider LoadBalancerEnabled * move calculate_easy_days_modifiers out of struct * improve naming & add comments * apply clippy * reschedule if easy days settings are changed * Minor simplification * refactor to share code between load balancer and rescheduler * intervals_and_params -> intervals_and_weights * find_best_interval -> select_weighted_interval * cargo clippy * add warning about easyDaysChanged * compare arrays directly * Don't show warning if fsrs+rescehdule is already enabled --------- Co-authored-by: Damien Elmes Co-authored-by: Jake Probst --- ftl/core/deck-config.ftl | 1 + rslib/src/deckconfig/update.rs | 3 + rslib/src/scheduler/answering/mod.rs | 2 +- rslib/src/scheduler/fsrs/memory_state.rs | 46 +++- rslib/src/scheduler/fsrs/mod.rs | 1 + rslib/src/scheduler/fsrs/rescheduler.rs | 182 ++++++++++++++ rslib/src/scheduler/states/load_balancer.rs | 229 ++++++++++-------- rslib/src/storage/card/deck_due_counts.sql | 14 ++ rslib/src/storage/card/mod.rs | 9 + rslib/src/storage/revlog/mod.rs | 12 + .../storage/revlog/studied_today_by_deck.sql | 14 ++ ts/routes/deck-options/EasyDays.svelte | 15 ++ 12 files changed, 423 insertions(+), 105 deletions(-) create mode 100644 rslib/src/scheduler/fsrs/rescheduler.rs create mode 100644 rslib/src/storage/card/deck_due_counts.sql create mode 100644 rslib/src/storage/revlog/studied_today_by_deck.sql diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 89ca62036..4b21340ec 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -293,6 +293,7 @@ deck-config-easy-days-normal = Normal deck-config-easy-days-reduced = Reduced deck-config-easy-days-minimum = Minimum deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'. +deck-config-easy-days-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options. ## Adding/renaming diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index fca6ba30b..af4bb7572 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -215,6 +215,7 @@ impl Collection { .unwrap_or_default(); let previous_params = previous_config.map(|c| c.fsrs_params()); let previous_retention = previous_config.map(|c| c.inner.desired_retention); + let previous_easy_days = previous_config.map(|c| &c.inner.easy_days_percentages); // if a selected (sub)deck, or its old config was removed, update deck to point // to new config @@ -242,9 +243,11 @@ impl Collection { // if params differ, memory state needs to be recomputed let current_params = current_config.map(|c| c.fsrs_params()); let current_retention = current_config.map(|c| c.inner.desired_retention); + let current_easy_days = current_config.map(|c| &c.inner.easy_days_percentages); if fsrs_toggled || previous_params != current_params || previous_retention != current_retention + || (req.fsrs_reschedule && previous_easy_days != current_easy_days) { decks_needing_memory_recompute .entry(current_config_id) diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index a0368a9df..fa7228561 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -608,7 +608,7 @@ impl Card { /// Return a consistent seed for a given card at a given number of reps. /// If for_reschedule is true, we use card.reps - 1 to match the previous /// review. -fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option { +pub(crate) fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option { let reps = if for_reschedule { card.reps.saturating_sub(1) } else { diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 309721319..5efef07a6 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -10,10 +10,12 @@ use fsrs::FSRS; use itertools::Itertools; use super::params::ignore_revlogs_before_ms_from_config; +use super::rescheduler::Rescheduler; use crate::card::CardType; use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; +use crate::scheduler::answering::get_fuzz_seed; use crate::scheduler::fsrs::params::reviews_for_fsrs; use crate::scheduler::fsrs::params::Params; use crate::scheduler::states::fuzz::with_review_fuzz; @@ -69,6 +71,10 @@ impl Collection { } else { None }; + let mut rescheduler = self + .get_config_bool(BoolKey::LoadBalancerEnabled) + .then(|| Rescheduler::new(self)) + .transpose()?; let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?; let historical_retention = req.as_ref().map(|w| w.historical_retention); let items = fsrs_items_for_memory_states( @@ -99,6 +105,10 @@ impl Collection { if let Some(state) = &card.memory_state { // or in (re)learning if card.ctype == CardType::Review { + let deck = self + .get_deck(card.original_or_current_deck_id())? + .or_not_found(card.original_or_current_deck_id())?; + let deckconfig_id = deck.config_id().unwrap(); // reschedule it let original_interval = card.interval; let interval = fsrs.next_interval( @@ -106,19 +116,41 @@ impl Collection { card.desired_retention.unwrap(), 0, ); - card.interval = with_review_fuzz( - card.get_fuzz_factor(true), - interval, - 1, - req.max_interval, - ); + card.interval = rescheduler + .as_mut() + .and_then(|r| { + r.find_interval( + interval, + 1, + req.max_interval, + days_elapsed as u32, + deckconfig_id, + get_fuzz_seed(&card, true), + ) + }) + .unwrap_or_else(|| { + with_review_fuzz( + card.get_fuzz_factor(true), + interval, + 1, + req.max_interval, + ) + }); let due = if card.original_due != 0 { &mut card.original_due } else { &mut card.due }; - *due = (timing.days_elapsed as i32) - days_elapsed + let new_due = (timing.days_elapsed as i32) - days_elapsed + card.interval as i32; + if let Some(rescheduler) = &mut rescheduler { + rescheduler.update_due_cnt_per_day( + *due, + new_due, + deckconfig_id, + ); + } + *due = new_due; // Add a rescheduled revlog entry if the last entry wasn't // rescheduled if !last_info.last_revlog_is_rescheduled { diff --git a/rslib/src/scheduler/fsrs/mod.rs b/rslib/src/scheduler/fsrs/mod.rs index fe2168a7f..859adabf9 100644 --- a/rslib/src/scheduler/fsrs/mod.rs +++ b/rslib/src/scheduler/fsrs/mod.rs @@ -4,6 +4,7 @@ mod error; pub mod memory_state; pub mod params; +pub mod rescheduler; pub mod retention; pub mod simulator; pub mod try_collect; diff --git a/rslib/src/scheduler/fsrs/rescheduler.rs b/rslib/src/scheduler/fsrs/rescheduler.rs new file mode 100644 index 000000000..335436707 --- /dev/null +++ b/rslib/src/scheduler/fsrs/rescheduler.rs @@ -0,0 +1,182 @@ +// 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 chrono::Datelike; + +use crate::prelude::*; +use crate::scheduler::states::fuzz::constrained_fuzz_bounds; +use crate::scheduler::states::load_balancer::build_easy_days_percentages; +use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers; +use crate::scheduler::states::load_balancer::select_weighted_interval; +use crate::scheduler::states::load_balancer::EasyDay; +use crate::scheduler::states::load_balancer::LoadBalancerInterval; + +pub struct Rescheduler { + today: i32, + next_day_at: TimestampSecs, + due_cnt_per_day_by_preset: HashMap>, + due_today_by_preset: HashMap, + reviewed_today_by_preset: HashMap, + easy_days_percentages_by_preset: HashMap, +} + +impl Rescheduler { + pub fn new(col: &mut Collection) -> Result { + let timing = col.timing_today()?; + let deck_stats = col.storage.get_deck_due_counts()?; + let deck_map = col.storage.get_decks_map()?; + let did_to_dcid = deck_map + .values() + .filter_map(|deck| Some((deck.id, deck.config_id()?))) + .collect::>(); + + let mut due_cnt_per_day_by_preset: HashMap> = + HashMap::new(); + for (did, due_date, count) in deck_stats { + let deck_config_id = did_to_dcid[&did]; + due_cnt_per_day_by_preset + .entry(deck_config_id) + .or_default() + .entry(due_date) + .and_modify(|e| *e += count) + .or_insert(count); + } + + let today = timing.days_elapsed as i32; + let due_today_by_preset = due_cnt_per_day_by_preset + .iter() + .map(|(deck_config_id, config_dues)| { + let due_today = config_dues + .iter() + .filter(|(&due, _)| due <= today) + .map(|(_, &count)| count) + .sum(); + (*deck_config_id, due_today) + }) + .collect(); + + let next_day_at = timing.next_day_at; + let reviewed_stats = col.storage.studied_today_by_deck(timing.next_day_at)?; + let mut reviewed_today_by_preset: HashMap = HashMap::new(); + for (did, count) in reviewed_stats { + if let Some(&deck_config_id) = &did_to_dcid.get(&did) { + *reviewed_today_by_preset.entry(deck_config_id).or_default() += count; + } + } + + let easy_days_percentages_by_preset = + build_easy_days_percentages(col.storage.get_deck_config_map()?)?; + + Ok(Self { + today, + next_day_at, + due_cnt_per_day_by_preset, + due_today_by_preset, + reviewed_today_by_preset, + easy_days_percentages_by_preset, + }) + } + + pub fn update_due_cnt_per_day( + &mut self, + due_before: i32, + due_after: i32, + deck_config_id: DeckConfigId, + ) { + if let Some(counts) = self.due_cnt_per_day_by_preset.get_mut(&deck_config_id) { + if let Some(count) = counts.get_mut(&due_before) { + *count -= 1; + } + *counts.entry(due_after).or_default() += 1; + } + + if due_before <= self.today && due_after > self.today { + if let Some(count) = self.due_today_by_preset.get_mut(&deck_config_id) { + *count -= 1; + } + } + if due_before > self.today && due_after <= self.today { + *self.due_today_by_preset.entry(deck_config_id).or_default() += 1; + } + } + + fn due_today(&self, deck_config_id: DeckConfigId) -> usize { + *self.due_today_by_preset.get(&deck_config_id).unwrap_or(&0) + } + + fn reviewed_today(&self, deck_config_id: DeckConfigId) -> usize { + *self + .reviewed_today_by_preset + .get(&deck_config_id) + .unwrap_or(&0) + } + + pub fn find_interval( + &self, + interval: f32, + minimum: u32, + maximum: u32, + days_elapsed: u32, + deckconfig_id: DeckConfigId, + fuzz_seed: Option, + ) -> Option { + let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum); + + // Don't reschedule the card when it's overdue + if after_days < days_elapsed { + return None; + } + // Don't reschedule the card to the past + let before_days = before_days.max(days_elapsed); + + // Generate possible intervals and their review counts + let possible_intervals: Vec = (before_days..=after_days).collect(); + let review_counts: Vec = possible_intervals + .iter() + .map(|&ivl| { + if ivl > days_elapsed { + let check_due = self.today + ivl as i32 - days_elapsed as i32; + *self + .due_cnt_per_day_by_preset + .get(&deckconfig_id) + .and_then(|counts| counts.get(&check_due)) + .unwrap_or(&0) + } else { + // today's workload is the sum of backlogs, cards due today and cards reviewed + // today + self.due_today(deckconfig_id) + self.reviewed_today(deckconfig_id) + } + }) + .collect(); + let weekdays: Vec = possible_intervals + .iter() + .map(|&ivl| { + self.next_day_at + .adding_secs(days_elapsed as i64 * -86400) + .adding_secs((ivl - 1) as i64 * 86400) + .local_datetime() + .unwrap() + .weekday() + .num_days_from_monday() as usize + }) + .collect(); + + let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; + let easy_days_modifier = + calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts); + + let intervals = + possible_intervals + .iter() + .enumerate() + .map(|(interval_index, &target_interval)| LoadBalancerInterval { + target_interval, + review_count: review_counts[interval_index], + sibling_modifier: 1.0, + easy_days_modifier: easy_days_modifier[interval_index], + }); + + select_weighted_interval(intervals, fuzz_seed) + } +} diff --git a/rslib/src/scheduler/states/load_balancer.rs b/rslib/src/scheduler/states/load_balancer.rs index 678dd9169..1436549f8 100644 --- a/rslib/src/scheduler/states/load_balancer.rs +++ b/rslib/src/scheduler/states/load_balancer.rs @@ -44,7 +44,7 @@ impl From for EasyDay { } impl EasyDay { - fn load_modifier(&self) -> f32 { + pub(crate) fn load_modifier(&self) -> f32 { match self { // this is a non-zero value so if all days are minimum, the load balancer will // proceed as normal @@ -161,27 +161,7 @@ impl LoadBalancer { }, ); let configs = storage.get_deck_config_map()?; - - let easy_days_percentages_by_preset = configs - .into_iter() - .map(|(dcid, conf)| { - let easy_days_percentages: [EasyDay; 7] = - if conf.inner.easy_days_percentages.is_empty() { - [EasyDay::Normal; 7] - } else { - TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages) - .map_err(|_| { - AnkiError::from(InvalidInputError { - message: "expected 7 days".into(), - source: None, - backtrace: None, - }) - })? - .map(EasyDay::from) - }; - Ok((dcid, easy_days_percentages)) - }) - .collect::, AnkiError>>()?; + let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?; Ok(LoadBalancer { days_by_preset, @@ -254,87 +234,30 @@ impl LoadBalancer { }) .unzip(); - // Determine which days to schedule to with respect to Easy Day settings - // If a day is Normal, it will always be an option to schedule to - // If a day is Minimum, it will almost never be an option to schedule to - // If a day is Reduced, it will look at the amount of cards due in the fuzz - // range to determine if scheduling a card on that day would put it - // above the reduced threshold or not. - // the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be - // used when calculating the day's weight. This turns the day on or off. - // Note that it does not actually set it to 0.0, but a small - // 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to - // handle a handful of zero-related corner cases. let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; - let total_review_count: usize = review_counts.iter().sum(); - let total_percents: f32 = weekdays - .iter() - .map(|&weekday| easy_days_load[weekday].load_modifier()) - .sum(); - let easy_days_modifier = weekdays - .iter() - .zip(review_counts.iter()) - .map(|(&weekday, &review_count)| { - let day = match easy_days_load[weekday] { - EasyDay::Reduced => { - const HALF: f32 = 0.5; - let other_days_review_total = (total_review_count - review_count) as f32; - let other_days_percent_total = total_percents - HALF; - let normalized_count = review_count as f32 / HALF; - let reduced_day_threshold = - other_days_review_total / other_days_percent_total; - if normalized_count > reduced_day_threshold { - EasyDay::Minimum - } else { - EasyDay::Normal - } - } - other => other, - }; - day.load_modifier() - }) - .collect::>(); + let easy_days_modifier = + calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts); - // calculate params for each day - let intervals_and_params = interval_days + let intervals = interval_days .iter() .enumerate() .map(|(interval_index, interval_day)| { - let target_interval = interval_index as u32 + before_days; + LoadBalancerInterval { + target_interval: interval_index as u32 + before_days, + review_count: review_counts[interval_index], + // if there is a sibling on this day, give it a very low weight + sibling_modifier: note_id + .and_then(|note_id| { + interval_day + .has_sibling(¬e_id) + .then_some(SIBLING_PENALTY) + }) + .unwrap_or(1.0), + easy_days_modifier: easy_days_modifier[interval_index], + } + }); - // if there is a sibling on this day, give it a very low weight - let sibling_multiplier = note_id - .and_then(|note_id| { - interval_day - .has_sibling(¬e_id) - .then_some(SIBLING_PENALTY) - }) - .unwrap_or(1.0); - - let weight = match review_counts[interval_index] { - 0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight - card_count => { - let card_count_weight = (1.0 / card_count as f32).powi(2); - let card_interval_weight = 1.0 / target_interval as f32; - - card_count_weight - * card_interval_weight - * sibling_multiplier - * easy_days_modifier[interval_index] - } - }; - - (target_interval, weight) - }) - .collect::>(); - - let mut rng = StdRng::seed_from_u64(fuzz_seed?); - - let weighted_intervals = - WeightedIndex::new(intervals_and_params.iter().map(|k| k.1)).ok()?; - - let selected_interval_index = weighted_intervals.sample(&mut rng); - Some(intervals_and_params[selected_interval_index].0) + select_weighted_interval(intervals, fuzz_seed) } pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) { @@ -354,6 +277,118 @@ impl LoadBalancer { } } +/// Build a mapping of deck config IDs to their easy days settings. +/// For each deck config, maintains an array of 7 EasyDay values representing +/// the load modifier for each day of the week. +pub(crate) fn build_easy_days_percentages( + configs: HashMap, +) -> Result> { + configs + .into_iter() + .map(|(dcid, conf)| { + let easy_days_percentages: [EasyDay; 7] = if conf.inner.easy_days_percentages.is_empty() + { + [EasyDay::Normal; 7] + } else { + TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages) + .map_err(|_| { + AnkiError::from(InvalidInputError { + message: "expected 7 days".into(), + source: None, + backtrace: None, + }) + })? + .map(EasyDay::from) + }; + Ok((dcid, easy_days_percentages)) + }) + .collect() +} + +// Determine which days to schedule to with respect to Easy Day settings +// If a day is Normal, it will always be an option to schedule to +// If a day is Minimum, it will almost never be an option to schedule to +// If a day is Reduced, it will look at the amount of cards due in the fuzz +// range to determine if scheduling a card on that day would put it +// above the reduced threshold or not. +// the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be +// used when calculating the day's weight. This turns the day on or off. +// Note that it does not actually set it to 0.0, but a small +// 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to +// handle a handful of zero-related corner cases. +pub(crate) fn calculate_easy_days_modifiers( + easy_days_load: &[EasyDay; 7], + weekdays: &[usize], + review_counts: &[usize], +) -> Vec { + let total_review_count: usize = review_counts.iter().sum(); + let total_percents: f32 = weekdays + .iter() + .map(|&weekday| easy_days_load[weekday].load_modifier()) + .sum(); + + weekdays + .iter() + .zip(review_counts.iter()) + .map(|(&weekday, &review_count)| { + let day = match easy_days_load[weekday] { + EasyDay::Reduced => { + const HALF: f32 = 0.5; + let other_days_review_total = (total_review_count - review_count) as f32; + let other_days_percent_total = total_percents - HALF; + let normalized_count = review_count as f32 / HALF; + let reduced_day_threshold = other_days_review_total / other_days_percent_total; + if normalized_count > reduced_day_threshold { + EasyDay::Minimum + } else { + EasyDay::Normal + } + } + other => other, + }; + day.load_modifier() + }) + .collect() +} + +pub struct LoadBalancerInterval { + pub target_interval: u32, + pub review_count: usize, + pub sibling_modifier: f32, + pub easy_days_modifier: f32, +} + +pub fn select_weighted_interval( + intervals: impl Iterator, + fuzz_seed: Option, +) -> Option { + let intervals_and_weights = intervals + .map(|interval| { + let weight = match interval.review_count { + 0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight + card_count => { + let card_count_weight = (1.0 / card_count as f32).powi(2); + let card_interval_weight = 1.0 / interval.target_interval as f32; + + card_count_weight + * card_interval_weight + * interval.sibling_modifier + * interval.easy_days_modifier + } + }; + + (interval.target_interval, weight) + }) + .collect::>(); + + let mut rng = StdRng::seed_from_u64(fuzz_seed?); + + let weighted_intervals = WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?; + + let selected_interval_index = weighted_intervals.sample(&mut rng); + Some(intervals_and_weights[selected_interval_index].0) +} + fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize { let target_datetime = next_day_at .adding_secs((interval - 1) as i64 * 86400) diff --git a/rslib/src/storage/card/deck_due_counts.sql b/rslib/src/storage/card/deck_due_counts.sql new file mode 100644 index 000000000..83d827e3e --- /dev/null +++ b/rslib/src/storage/card/deck_due_counts.sql @@ -0,0 +1,14 @@ +SELECT CASE + WHEN odid == 0 THEN did + ELSE odid + END AS original_did, + CASE + WHEN odid == 0 THEN due + ELSE odue + END AS true_due, + COUNT() AS COUNT +FROM cards +WHERE type = 2 + AND queue != -1 +GROUP BY original_did, + true_due \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 6b19b034b..a699d5ef2 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -624,6 +624,15 @@ impl super::SqliteStorage { )) } + pub(crate) fn get_deck_due_counts(&self) -> Result> { + self.db + .prepare(include_str!("deck_due_counts.sql"))? + .query_and_then([], |row| -> Result<_> { + Ok((DeckId(row.get(0)?), row.get(1)?, row.get(2)?)) + })? + .collect() + } + pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result { // NOTE: this line is obsolete in v3 as it's run on queue build, but kept to // prevent errors for v1/v2 users before they upgrade diff --git a/rslib/src/storage/revlog/mod.rs b/rslib/src/storage/revlog/mod.rs index c6ff49271..2c0a553fe 100644 --- a/rslib/src/storage/revlog/mod.rs +++ b/rslib/src/storage/revlog/mod.rs @@ -201,6 +201,18 @@ impl SqliteStorage { .map_err(Into::into) } + pub(crate) fn studied_today_by_deck( + &self, + day_cutoff: TimestampSecs, + ) -> Result> { + let start = day_cutoff.adding_secs(-86_400).as_millis(); + self.db + .prepare_cached(include_str!("studied_today_by_deck.sql"))? + .query_and_then([start.0], |row| -> Result<_> { + Ok((DeckId(row.get(0)?), row.get(1)?)) + })? + .collect() + } pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> { self.db .execute_batch(include_str!("v2_upgrade.sql")) diff --git a/rslib/src/storage/revlog/studied_today_by_deck.sql b/rslib/src/storage/revlog/studied_today_by_deck.sql new file mode 100644 index 000000000..0cb5871d2 --- /dev/null +++ b/rslib/src/storage/revlog/studied_today_by_deck.sql @@ -0,0 +1,14 @@ +SELECT CASE + WHEN c.odid == 0 THEN c.did + ELSE c.odid + END AS original_did, + COUNT(DISTINCT r.cid) AS cnt +FROM revlog AS r + JOIN cards AS c ON r.cid = c.id +WHERE r.id > ? + AND r.ease > 0 + AND ( + r.type < 3 + OR r.factor != 0 + ) +GROUP BY original_did \ No newline at end of file diff --git a/ts/routes/deck-options/EasyDays.svelte b/ts/routes/deck-options/EasyDays.svelte index 0bcf1c90c..aea4e026e 100644 --- a/ts/routes/deck-options/EasyDays.svelte +++ b/ts/routes/deck-options/EasyDays.svelte @@ -13,17 +13,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let state: DeckOptionsState; export let api: Record; + const fsrsEnabled = state.fsrs; + const reschedule = state.fsrsReschedule; const config = state.currentConfig; const defaults = state.defaults; + const prevEasyDaysPercentages = $config.easyDaysPercentages.slice(); $: if ($config.easyDaysPercentages.length !== 7) { $config.easyDaysPercentages = defaults.easyDaysPercentages.slice(); } + $: easyDaysChanged = $config.easyDaysPercentages.some( + (value, index) => value !== prevEasyDaysPercentages[index], + ); + $: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0) ? "" : tr.deckConfigEasyDaysNoNormalDays(); + $: rescheduleWarning = + easyDaysChanged && !($fsrsEnabled && $reschedule) + ? tr.deckConfigEasyDaysChange() + : ""; + const easyDays = [ tr.deckConfigEasyDaysMonday(), tr.deckConfigEasyDaysTuesday(), @@ -81,6 +93,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + + +