mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Congratulations Screen with 7-Day future due graph
This commit is contained in:
parent
f3b4284afb
commit
6f8e7fa722
5 changed files with 265 additions and 1 deletions
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SchedulerInfo> {
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
|
142
ts/routes/congrats/CongratsFutureDue.svelte
Normal file
142
ts/routes/congrats/CongratsFutureDue.svelte
Normal 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>
|
|
@ -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}
|
||||
</div>
|
||||
{/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>
|
||||
</Col>
|
||||
</Container>
|
||||
|
|
Loading…
Reference in a new issue