diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 34b350642..6fa94ce3a 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -193,6 +193,15 @@ message CongratsInfoResponse { bool is_filtered_deck = 7; bool bridge_commands_supported = 8; string deck_description = 9; + repeated ReviewForecastDay forecast = 10; +} + +message ReviewForecastDay { + uint32 day_offset = 1; + uint32 total = 2; + uint32 review = 3; + uint32 learn = 4; + uint32 new = 5; } message UnburyDeckRequest { diff --git a/rslib/src/scheduler/congrats.rs b/rslib/src/scheduler/congrats.rs index 721bd417b..c99526b95 100644 --- a/rslib/src/scheduler/congrats.rs +++ b/rslib/src/scheduler/congrats.rs @@ -20,6 +20,7 @@ impl Collection { let info = self.storage.congrats_info(&deck, today)?; let is_filtered_deck = deck.is_filtered(); let deck_description = deck.rendered_description(); + let forecast = self.sched_forecast(8).unwrap_or_default(); let secs_until_next_learn = if info.next_learn_due == 0 { // signal to the frontend that no learning cards are due later 86_400 @@ -37,6 +38,7 @@ impl Collection { secs_until_next_learn, bridge_commands_supported: true, deck_description, + forecast, }) } } @@ -49,6 +51,15 @@ mod test { fn empty() { let mut col = Collection::new(); let info = col.congrats_info().unwrap(); + let expected_forecast = (0..7) + .map(|offset| anki_proto::scheduler::ReviewForecastDay { + day_offset: offset, + total: 0, + review: 0, + learn: 0, + new: 0, + }) + .collect(); assert_eq!( info, anki_proto::scheduler::CongratsInfoResponse { @@ -60,8 +71,48 @@ mod test { is_filtered_deck: false, secs_until_next_learn: 86_400, bridge_commands_supported: true, - deck_description: "".to_string() + deck_description: "".to_string(), + forecast: expected_forecast } ) } + + #[test] + fn cards_added_to_graph() { + let mut col = Collection::new(); + let timing = col.timing_today().unwrap(); + let today = timing.days_elapsed; + // Create a simple card directly in the database + col.storage.db.execute_batch(&format!( + "INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data) + VALUES + (1, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, ''), + (2, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, ''), + (3, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, '')", + timing.now.0, + today, // Card 1 due today + timing.now.0, + today + 1, // Card 2 due tomorrow + timing.now.0, + today + 2, // Card 3 due day after tomorrow + )).unwrap(); + let forecast = col.sched_forecast(7).unwrap(); + // Check that cards appear on the correct days + assert_eq!(forecast[0].total, 1); // Today: 1 card + assert_eq!(forecast[0].review, 1); + assert_eq!(forecast[1].total, 1); // Tomorrow: 1 card + assert_eq!(forecast[1].review, 1); + assert_eq!(forecast[2].total, 1); // Day 2: 1 card + assert_eq!(forecast[2].review, 1); + // Days 3-6 should have no cards + for day in forecast.iter().skip(3).take(4) { + assert_eq!(day.total, 0); + assert_eq!(day.review, 0); + } + // All days should have learn = 0, new = 0 (current implementation) + for day in &forecast { + assert_eq!(day.learn, 0); + assert_eq!(day.new, 0); + } + } } diff --git a/rslib/src/scheduler/mod.rs b/rslib/src/scheduler/mod.rs index 93aee3c9b..f95e12c47 100644 --- a/rslib/src/scheduler/mod.rs +++ b/rslib/src/scheduler/mod.rs @@ -31,6 +31,15 @@ pub struct SchedulerInfo { pub timing: SchedTimingToday, } +#[derive(Debug, Clone)] +pub struct ReviewForecastDay { + pub day_offset: u32, + pub total: u32, + pub review: u32, + pub learn: u32, + pub new: u32, +} + impl Collection { pub fn scheduler_info(&mut self) -> Result { let now = TimestampSecs::now(); @@ -132,4 +141,36 @@ impl Collection { self.state.scheduler_info = None; self.storage.set_creation_stamp(stamp) } + + /// Return forecast data for the next `days` days (capped at 7). + pub(crate) fn sched_forecast( + &mut self, + days: u32, + ) -> Result> { + use anki_proto::scheduler::ReviewForecastDay as PbDay; + let timing = self.timing_for_timestamp(TimestampSecs::now())?; + let today = timing.days_elapsed; + let mut out = Vec::new(); + let want = days.min(7); + + for offset in 0..want { + let target_day = today + offset; + let rev_cnt = self + .storage + .db + .prepare_cached("SELECT COUNT(*) FROM cards WHERE queue = 2 AND due = ?") + .and_then(|mut stmt| { + stmt.query_row([(target_day as i64)], |row| row.get::<_, u32>(0)) + }) + .unwrap_or(0); + out.push(PbDay { + day_offset: offset, + total: rev_cnt, + review: rev_cnt, + learn: 0, + new: 0, + }); + } + Ok(out) + } } diff --git a/ts/routes/congrats/CongratsFutureDue.svelte b/ts/routes/congrats/CongratsFutureDue.svelte new file mode 100644 index 000000000..17b84d484 --- /dev/null +++ b/ts/routes/congrats/CongratsFutureDue.svelte @@ -0,0 +1,142 @@ + + + +
+
+

{title}

+
+ +
+ {#if chartData.some((d) => d.count > 0)} + + {:else} +
No cards due in the next 7 days
+ {/if} +
+
diff --git a/ts/routes/congrats/CongratsPage.svelte b/ts/routes/congrats/CongratsPage.svelte index 94187479e..34c41c7f7 100644 --- a/ts/routes/congrats/CongratsPage.svelte +++ b/ts/routes/congrats/CongratsPage.svelte @@ -10,6 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Col from "$lib/components/Col.svelte"; import Container from "$lib/components/Container.svelte"; + import CongratsFutureDue from "./CongratsFutureDue.svelte"; import { buildNextLearnMsg } from "./lib"; import { onMount } from "svelte"; @@ -30,6 +31,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html customStudy, }); + $: forecastData = (() => { + const forecast = (info as any).forecast || []; + return forecast.map((day: any) => ({ + review: day.review || 0, + learn: day.learn || 0, + new: day.new || 0, + total: (day.review || 0) + (day.learn || 0) + (day.new || 0), + })); + })(); + onMount(() => { if (refreshPeriodically) { setInterval(async () => { @@ -77,6 +88,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {@html info.deckDescription} {/if} + + {#if forecastData.length > 0 && forecastData.some((d) => d.total > 0)} +
+

+ Below is your study forecast for the upcoming week. This shows + how many cards you'll need to review each day for this deck. +

+ +
+ {/if}