mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Merge b5e7c7c6f0
into 3890e12c9e
This commit is contained in:
commit
8a8e6c5429
5 changed files with 265 additions and 1 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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 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>
|
||||||
|
|
Loading…
Reference in a new issue