diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 89baec817..1d202cb82 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -277,6 +277,19 @@ deck-config-new-interval-tooltip = The multiplier applied to a review interval w deck-config-minimum-interval-tooltip = The minimum interval given to a review card after answering `Again`. deck-config-custom-scheduling = Custom scheduling deck-config-custom-scheduling-tooltip = Affects the entire collection. Use at your own risk! +# Easy Days section + +deck-config-easy-days-title = Easy Days +deck-config-easy-days-monday = Monday +deck-config-easy-days-tuesday = Tuesday +deck-config-easy-days-wednesday = Wednesday +deck-config-easy-days-thursday = Thursday +deck-config-easy-days-friday = Friday +deck-config-easy-days-saturday = Saturday +deck-config-easy-days-sunday = Sunday +deck-config-easy-days-normal = Normal +deck-config-easy-days-reduced = Reduced +deck-config-easy-days-minimum = Minimum ## Adding/renaming diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index fbd1158f8..179e98b3e 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -109,7 +109,7 @@ message DeckConfig { repeated float fsrs_weights = 3; - reserved 4 to 8; + reserved 5 to 8; uint32 new_per_day = 9; uint32 reviews_per_day = 10; @@ -159,6 +159,7 @@ message DeckConfig { // for fsrs float desired_retention = 37; string ignore_revlogs_before_date = 46; + repeated float easy_days_percentages = 4; // used for fsrs_reschedule in the past reserved 39; float historical_retention = 40; diff --git a/rslib/src/deckconfig/mod.rs b/rslib/src/deckconfig/mod.rs index d11bc43f3..489537d42 100644 --- a/rslib/src/deckconfig/mod.rs +++ b/rslib/src/deckconfig/mod.rs @@ -80,6 +80,7 @@ const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner { historical_retention: 0.9, weight_search: String::new(), ignore_revlogs_before_date: String::new(), + easy_days_percentages: Vec::new(), }; impl Default for DeckConfig { @@ -92,6 +93,7 @@ impl Default for DeckConfig { inner: DeckConfigInner { learn_steps: vec![1.0, 10.0], relearn_steps: vec![10.0], + easy_days_percentages: vec![1.0; 7], ..DEFAULT_DECK_CONFIG_INNER }, } diff --git a/rslib/src/deckconfig/schema11.rs b/rslib/src/deckconfig/schema11.rs index 2219802b0..c03c62d14 100644 --- a/rslib/src/deckconfig/schema11.rs +++ b/rslib/src/deckconfig/schema11.rs @@ -76,6 +76,8 @@ pub struct DeckConfSchema11 { #[serde(default)] ignore_revlogs_before_date: String, #[serde(default)] + easy_days_percentages: Vec, + #[serde(default)] stop_timer_on_answer: bool, #[serde(default)] seconds_to_show_question: f32, @@ -309,6 +311,7 @@ impl Default for DeckConfSchema11 { sm2_retention: 0.9, weight_search: "".to_string(), ignore_revlogs_before_date: "".to_string(), + easy_days_percentages: vec![1.0; 7], } } } @@ -385,6 +388,7 @@ impl From for DeckConfig { bury_interday_learning: c.bury_interday_learning, fsrs_weights: c.fsrs_weights, ignore_revlogs_before_date: c.ignore_revlogs_before_date, + easy_days_percentages: c.easy_days_percentages, desired_retention: c.desired_retention, historical_retention: c.sm2_retention, weight_search: c.weight_search, @@ -499,6 +503,7 @@ impl From for DeckConfSchema11 { sm2_retention: i.historical_retention, weight_search: i.weight_search, ignore_revlogs_before_date: i.ignore_revlogs_before_date, + easy_days_percentages: i.easy_days_percentages, } } } @@ -531,6 +536,7 @@ static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! { "sm2Retention", "weightSearch", "ignoreRevlogsBeforeDate", + "easyDaysPercentages", }; static RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! { diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 6567e922b..a95d056a8 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -150,7 +150,12 @@ impl QueueBuilder { .values() .filter_map(|deck| Some((deck.id, deck.config_id()?))) .collect::>(); - let load_balancer = LoadBalancer::new(timing.days_elapsed, did_to_dcid, &col.storage)?; + let load_balancer = LoadBalancer::new( + timing.days_elapsed, + did_to_dcid, + col.timing_today()?.next_day_at, + &col.storage, + )?; Ok(QueueBuilder { new: Vec::new(), diff --git a/rslib/src/scheduler/states/load_balancer.rs b/rslib/src/scheduler/states/load_balancer.rs index 99fa9b39b..5ba2103e1 100644 --- a/rslib/src/scheduler/states/load_balancer.rs +++ b/rslib/src/scheduler/states/load_balancer.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::collections::HashSet; +use chrono::Datelike; use rand::distributions::Distribution; use rand::distributions::WeightedIndex; use rand::rngs::StdRng; @@ -12,6 +13,7 @@ use rand::SeedableRng; use super::fuzz::constrained_fuzz_bounds; use crate::card::CardId; use crate::deckconfig::DeckConfigId; +use crate::error::InvalidInputError; use crate::notes::NoteId; use crate::prelude::*; use crate::storage::SqliteStorage; @@ -82,12 +84,15 @@ pub struct LoadBalancer { /// Load balancer operates at the preset level, it only counts /// cards in the same preset as the card being balanced. days_by_preset: HashMap, + easy_days_percentages_by_preset: HashMap, + next_day_at: TimestampSecs, } impl LoadBalancer { pub fn new( today: u32, did_to_dcid: HashMap, + next_day_at: TimestampSecs, storage: &SqliteStorage, ) -> Result { let cards_on_each_day = @@ -126,8 +131,29 @@ impl LoadBalancer { deckconfig_group }, ); + let configs = storage.get_deck_config_map()?; - Ok(LoadBalancer { days_by_preset }) + let mut easy_days_percentages_by_preset = HashMap::with_capacity(configs.len()); + for (dcid, conf) in configs { + let easy_days_percentages = if conf.inner.easy_days_percentages.is_empty() { + [1.0; 7] + } else { + conf.inner.easy_days_percentages.try_into().map_err(|_| { + AnkiError::from(InvalidInputError { + message: "expected 7 days".into(), + source: None, + backtrace: None, + }) + })? + }; + easy_days_percentages_by_preset.insert(dcid, easy_days_percentages); + } + + Ok(LoadBalancer { + days_by_preset, + easy_days_percentages_by_preset, + next_day_at, + }) } pub fn review_context( @@ -182,6 +208,24 @@ impl LoadBalancer { let days = self.days_by_preset.get(&deckconfig_id)?; let interval_days = &days[before_days as usize..=after_days as usize]; + // calculate review counts and expected distribution + let (review_counts, weekdays): (Vec, Vec) = interval_days + .iter() + .enumerate() + .map(|(i, day)| { + ( + day.cards.len(), + interval_to_weekday(i as u32 + before_days, self.next_day_at), + ) + }) + .unzip(); + let easy_days_percentages = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; + let percentages = weekdays + .iter() + .map(|&wd| easy_days_percentages[wd]) + .collect::>(); + let expected_distribution = check_review_distribution(&review_counts, &percentages); + // calculate weights for each day let intervals_and_weights = interval_days .iter() @@ -198,7 +242,7 @@ impl LoadBalancer { }) .unwrap_or(1.0); - let weight = match interval_day.cards.len() { + 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); @@ -208,7 +252,10 @@ impl LoadBalancer { } }; - (target_interval, weight) + ( + target_interval, + weight * expected_distribution[interval_index], + ) }) .collect::>(); @@ -237,3 +284,27 @@ impl LoadBalancer { } } } + +fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize { + let target_datetime = next_day_at + .adding_secs((interval - 1) as i64 * 86400) + .local_datetime() + .unwrap(); + target_datetime.weekday().num_days_from_monday() as usize +} + +fn check_review_distribution(actual_reviews: &[usize], percentages: &[f32]) -> Vec { + if percentages.iter().sum::() == 0.0 { + return vec![1.0; actual_reviews.len()]; + } + let total_actual = actual_reviews.iter().sum::() as f32; + let expected_distribution: Vec = percentages + .iter() + .map(|&p| p * (total_actual / percentages.iter().sum::())) + .collect(); + expected_distribution + .iter() + .zip(actual_reviews.iter()) + .map(|(&e, &a)| (e - a as f32).max(0.0)) + .collect() +} diff --git a/ts/routes/deck-options/DeckOptionsPage.svelte b/ts/routes/deck-options/DeckOptionsPage.svelte index ce5234bb2..772b2a20a 100644 --- a/ts/routes/deck-options/DeckOptionsPage.svelte +++ b/ts/routes/deck-options/DeckOptionsPage.svelte @@ -25,6 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { DeckOptionsState } from "./lib"; import NewOptions from "./NewOptions.svelte"; import TimerOptions from "./TimerOptions.svelte"; + import EasyDays from "./EasyDays.svelte"; export let state: DeckOptionsState; const addons = state.addonComponents; @@ -57,6 +58,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const timerOptions = {}; export const audioOptions = {}; export const advancedOptions = {}; + export const easyDays = {}; let onPresetChange: () => void; @@ -88,11 +90,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - +
+ + + + @@ -112,7 +118,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} - + diff --git a/ts/routes/deck-options/EasyDays.svelte b/ts/routes/deck-options/EasyDays.svelte new file mode 100644 index 000000000..cfa0c85fa --- /dev/null +++ b/ts/routes/deck-options/EasyDays.svelte @@ -0,0 +1,97 @@ + + + + + + +
+ + + + + + + + + + + {#each easyDays as day, index} + + + + + + + {/each} + +
{tr.deckConfigEasyDaysNormal()}{tr.deckConfigEasyDaysReduced()}{tr.deckConfigEasyDaysMinimum()}
{day} + + + + + +
+
+
+
+
+ + diff --git a/ts/routes/graphs/retrievability.ts b/ts/routes/graphs/retrievability.ts index 6774bcdf9..072e6ab73 100644 --- a/ts/routes/graphs/retrievability.ts +++ b/ts/routes/graphs/retrievability.ts @@ -111,7 +111,7 @@ export function prepareData( }, { label: tr.statisticsEstimatedTotalKnowledge(), - value: tr.statisticsCards({ cards: data.sum }), + value: tr.statisticsCards({ cards: +data.sum.toFixed(0) }), }, ]; diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index aaee87a90..1ebd0a4ae 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -81,6 +81,7 @@ export function renderSimulationChart( ) .attr("direction", "ltr"); + svg.select(".y-ticks .y-axis-title").remove(); svg.select(".y-ticks") .append("text") .attr("class", "y-axis-title")