Congratulations Screen with 7-Day future due graph

This commit is contained in:
Thomas Rixen 2025-08-18 01:07:21 +02:00
parent f3b4284afb
commit 6f8e7fa722
5 changed files with 265 additions and 1 deletions

View file

@ -193,6 +193,15 @@ message CongratsInfoResponse {
bool is_filtered_deck = 7; bool is_filtered_deck = 7;
bool bridge_commands_supported = 8; bool bridge_commands_supported = 8;
string deck_description = 9; 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 { message UnburyDeckRequest {

View file

@ -20,6 +20,7 @@ impl Collection {
let info = self.storage.congrats_info(&deck, today)?; let info = self.storage.congrats_info(&deck, today)?;
let is_filtered_deck = deck.is_filtered(); let is_filtered_deck = deck.is_filtered();
let deck_description = deck.rendered_description(); 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 { let secs_until_next_learn = if info.next_learn_due == 0 {
// signal to the frontend that no learning cards are due later // signal to the frontend that no learning cards are due later
86_400 86_400
@ -37,6 +38,7 @@ impl Collection {
secs_until_next_learn, secs_until_next_learn,
bridge_commands_supported: true, bridge_commands_supported: true,
deck_description, deck_description,
forecast,
}) })
} }
} }
@ -49,6 +51,15 @@ mod test {
fn empty() { fn empty() {
let mut col = Collection::new(); let mut col = Collection::new();
let info = col.congrats_info().unwrap(); 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!( assert_eq!(
info, info,
anki_proto::scheduler::CongratsInfoResponse { anki_proto::scheduler::CongratsInfoResponse {
@ -60,8 +71,48 @@ mod test {
is_filtered_deck: false, is_filtered_deck: false,
secs_until_next_learn: 86_400, secs_until_next_learn: 86_400,
bridge_commands_supported: true, 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);
}
}
} }

View file

@ -31,6 +31,15 @@ pub struct SchedulerInfo {
pub timing: SchedTimingToday, 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 { impl Collection {
pub fn scheduler_info(&mut self) -> Result<SchedulerInfo> { pub fn scheduler_info(&mut self) -> Result<SchedulerInfo> {
let now = TimestampSecs::now(); let now = TimestampSecs::now();
@ -132,4 +141,36 @@ impl Collection {
self.state.scheduler_info = None; self.state.scheduler_info = None;
self.storage.set_creation_stamp(stamp) 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<Vec<anki_proto::scheduler::ReviewForecastDay>> {
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)
}
} }

View file

@ -0,0 +1,142 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { afterUpdate } from "svelte";
import {
scaleLinear,
scaleBand,
axisBottom,
axisLeft,
select,
max,
interpolateOranges,
scaleSequential,
} from "d3";
export let forecastData: Array<{
total: number;
}>;
let svgElement: SVGElement;
let chartData: Array<{ day: string; count: number }> = [];
$: chartData = forecastData.slice(1, 8).map((day, idx) => ({
day:
["Tomorrow", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6", "Day 7"][idx] ||
`Day ${idx + 2}`,
count: day.total,
}));
const margin = { top: 20, right: 20, bottom: 40, left: 40 };
const width = 450 - margin.left - margin.right;
const height = 160 - margin.top - margin.bottom;
function drawChart() {
if (!svgElement) {
return;
}
select(svgElement).selectAll("*").remove();
const svg = select(svgElement)
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const xScale = scaleBand()
.domain(chartData.map((d) => d.day))
.range([0, width])
.padding(0.2);
const maxCount = max(chartData, (d) => d.count) || 1;
const yScale = scaleLinear().domain([0, maxCount]).nice().range([height, 0]);
const colorScale = scaleSequential(interpolateOranges).domain([0, maxCount]);
const xAxis = axisBottom(xScale);
const yAxis = axisLeft(yScale)
.ticks(Math.min(5, maxCount))
.tickFormat((d) => (Number.isInteger(d) ? d.toString() : ""));
g.append("g")
.attr("class", "axis-x")
.attr("transform", `translate(0,${height})`)
.call(xAxis)
.selectAll("text")
.style("text-anchor", "middle")
.style("font-size", "10px")
.style("opacity", "0.7");
g.append("g")
.attr("class", "axis-y")
.call(yAxis)
.selectAll("text")
.style("font-size", "10px")
.style("opacity", "0.7");
g.selectAll(".bar")
.data(chartData)
.enter()
.append("rect")
.attr("class", "bar")
.attr("x", (d) => xScale(d.day)!)
.attr("width", xScale.bandwidth())
.attr("y", (d) => yScale(d.count))
.attr("height", (d) => height - yScale(d.count))
.attr("fill", (d) => (d.count > 0 ? colorScale(d.count) : "#f0f0f0"))
.attr("stroke", "none")
.style("shape-rendering", "crispEdges");
g.selectAll(".label")
.data(chartData.filter((d) => d.count > 0))
.enter()
.append("text")
.attr("class", "label")
.attr("x", (d) => xScale(d.day)! + xScale.bandwidth() / 2)
.attr("y", (d) => yScale(d.count) - 5)
.attr("text-anchor", "middle")
.style("font-size", "10px")
.style("font-weight", "bold")
.style("opacity", "0.8")
.style("fill", "#333")
.text((d) => d.count);
g.selectAll(".axis-y .tick line").style("opacity", "0.1").attr("x2", width);
g.selectAll(".axis-x .tick line").style("opacity", "0.1");
g.selectAll(".domain").style("opacity", "0.2");
}
$: if (chartData.length > 0) {
setTimeout(drawChart, 10);
}
afterUpdate(() => {
if (chartData.length > 0) {
drawChart();
}
});
const title = tr.statisticsFutureDueTitle();
</script>
<div class="future-due-container">
<div class="graph-header">
<h3 class="graph-title">{title}</h3>
</div>
<div class="chart-container">
{#if chartData.some((d) => d.count > 0)}
<svg bind:this={svgElement}></svg>
{:else}
<div class="no-data">No cards due in the next 7 days</div>
{/if}
</div>
</div>

View file

@ -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 Col from "$lib/components/Col.svelte";
import Container from "$lib/components/Container.svelte"; import Container from "$lib/components/Container.svelte";
import CongratsFutureDue from "./CongratsFutureDue.svelte";
import { buildNextLearnMsg } from "./lib"; import { buildNextLearnMsg } from "./lib";
import { onMount } from "svelte"; import { onMount } from "svelte";
@ -30,6 +31,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
customStudy, 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(() => { onMount(() => {
if (refreshPeriodically) { if (refreshPeriodically) {
setInterval(async () => { setInterval(async () => {
@ -77,6 +88,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{@html info.deckDescription} {@html info.deckDescription}
</div> </div>
{/if} {/if}
{#if forecastData.length > 0 && forecastData.some((d) => d.total > 0)}
<div class="graph-section">
<p class="graph-description">
Below is your study forecast for the upcoming week. This shows
how many cards you'll need to review each day for this deck.
</p>
<CongratsFutureDue {forecastData} />
</div>
{/if}
</div> </div>
</Col> </Col>
</Container> </Container>