mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 05:52:22 -04:00
Compare commits
13 commits
1253b09fef
...
8a8e6c5429
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8a8e6c5429 | ||
![]() |
3890e12c9e | ||
![]() |
80cff16250 | ||
![]() |
75d9026be5 | ||
![]() |
6854d13b88 | ||
![]() |
29072654db | ||
![]() |
ec6f09958a | ||
![]() |
c2957746f4 | ||
![]() |
9e415869b8 | ||
![]() |
7e8a1076c1 | ||
![]() |
b97fb45e06 | ||
![]() |
b5e7c7c6f0 | ||
![]() |
6f8e7fa722 |
21 changed files with 406 additions and 36 deletions
2
.version
2
.version
|
@ -1 +1 @@
|
|||
25.09
|
||||
25.09.2
|
||||
|
|
|
@ -49,6 +49,7 @@ Sander Santema <github.com/sandersantema/>
|
|||
Thomas Brownback <https://github.com/brownbat/>
|
||||
Andrew Gaul <andrew@gaul.org>
|
||||
kenden
|
||||
Emil Hamrin <github.com/e-hamrin>
|
||||
Nickolay Yudin <kelciour@gmail.com>
|
||||
neitrinoweb <github.com/neitrinoweb/>
|
||||
Andreas Reis <github.com/nwwt>
|
||||
|
|
|
@ -1,35 +1,78 @@
|
|||
# This Dockerfile uses three stages.
|
||||
# 1. Compile anki (and dependencies) and build python wheels.
|
||||
# 2. Create a virtual environment containing anki and its dependencies.
|
||||
# 3. Create a final image that only includes anki's virtual environment and required
|
||||
# system packages.
|
||||
# This is a user-contributed Dockerfile. No official support is available.
|
||||
|
||||
ARG PYTHON_VERSION="3.9"
|
||||
ARG DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
# Build anki.
|
||||
FROM python:$PYTHON_VERSION AS build
|
||||
RUN curl -fsSL https://github.com/bazelbuild/bazelisk/releases/download/v1.7.4/bazelisk-linux-amd64 \
|
||||
> /usr/local/bin/bazel \
|
||||
&& chmod +x /usr/local/bin/bazel \
|
||||
# Bazel expects /usr/bin/python
|
||||
&& ln -s /usr/local/bin/python /usr/bin/python
|
||||
FROM ubuntu:24.04 AS build
|
||||
WORKDIR /opt/anki
|
||||
COPY . .
|
||||
# Build python wheels.
|
||||
ENV PYTHON_VERSION="3.13"
|
||||
|
||||
|
||||
# System deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libbz2-dev \
|
||||
libreadline-dev \
|
||||
libsqlite3-dev \
|
||||
libffi-dev \
|
||||
zlib1g-dev \
|
||||
liblzma-dev \
|
||||
ca-certificates \
|
||||
ninja-build \
|
||||
rsync \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libx11-6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libxkbcommon0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libxcb1 \
|
||||
libxcb-render0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-xinput0 \
|
||||
libsm6 \
|
||||
libice6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install rust with rustup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install uv and Python 3.13 with uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
RUN uv python install ${PYTHON_VERSION} --default
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN ./tools/build
|
||||
|
||||
|
||||
# Install pre-compiled Anki.
|
||||
FROM python:${PYTHON_VERSION}-slim as installer
|
||||
FROM python:3.13-slim AS installer
|
||||
WORKDIR /opt/anki/
|
||||
COPY --from=build /opt/anki/wheels/ wheels/
|
||||
COPY --from=build /opt/anki/out/wheels/ wheels/
|
||||
# Use virtual environment.
|
||||
RUN python -m venv venv \
|
||||
&& ./venv/bin/python -m pip install --no-cache-dir setuptools wheel \
|
||||
&& ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl
|
||||
|
||||
|
||||
# We use another build stage here so we don't include the wheels in the final image.
|
||||
FROM python:${PYTHON_VERSION}-slim as final
|
||||
FROM python:3.13-slim AS final
|
||||
COPY --from=installer /opt/anki/venv /opt/anki/venv
|
||||
ENV PATH=/opt/anki/venv/bin:$PATH
|
||||
# Install run-time dependencies.
|
||||
|
@ -59,9 +102,9 @@ RUN apt-get update \
|
|||
libxrender1 \
|
||||
libxtst6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add non-root user.
|
||||
RUN useradd --create-home anki
|
||||
USER anki
|
||||
WORKDIR /work
|
||||
ENTRYPOINT ["/opt/anki/venv/bin/anki"]
|
||||
LABEL maintainer="Jakub Kaczmarzyk <jakub.kaczmarzyk@gmail.com>"
|
||||
ENTRYPOINT ["/opt/anki/venv/bin/anki"]
|
|
@ -1 +1 @@
|
|||
Subproject commit 60bd4d4834b57b85915f52eb978935ff6b8c1425
|
||||
Subproject commit 480ef0da728c7ea3485c58529ae7ee02be3e5dba
|
|
@ -46,6 +46,20 @@ studying-type-answer-unknown-field = Type answer: unknown field { $val }
|
|||
studying-unbury = Unbury
|
||||
studying-what-would-you-like-to-unbury = What would you like to unbury?
|
||||
studying-you-havent-recorded-your-voice-yet = You haven't recorded your voice yet.
|
||||
studying-card-studied-in-minute =
|
||||
{ $cards ->
|
||||
[one] { $cards } card
|
||||
*[other] { $cards } cards
|
||||
} studied in
|
||||
{ $minutes ->
|
||||
[one] { $minutes } minute.
|
||||
*[other] { $minutes } minutes.
|
||||
}
|
||||
studying-question-time-elapsed = Question time elapsed
|
||||
studying-answer-time-elapsed = Answer time elapsed
|
||||
|
||||
## OBSOLETE; you do not need to translate this
|
||||
|
||||
studying-card-studied-in =
|
||||
{ $count ->
|
||||
[one] { $count } card studied in
|
||||
|
@ -56,5 +70,3 @@ studying-minute =
|
|||
[one] { $count } minute.
|
||||
*[other] { $count } minutes.
|
||||
}
|
||||
studying-question-time-elapsed = Question time elapsed
|
||||
studying-answer-time-elapsed = Answer time elapsed
|
||||
|
|
|
@ -27,6 +27,9 @@ service FrontendService {
|
|||
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
|
||||
// Warns python that the deck option web view is ready to receive requests.
|
||||
rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
|
||||
|
||||
// Save colour picker's custom colour palette
|
||||
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
|
||||
}
|
||||
|
||||
service BackendFrontendService {}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -13,7 +13,7 @@ import aqt.browser
|
|||
from anki.cards import Card
|
||||
from anki.collection import Config
|
||||
from anki.tags import MARKED_TAG
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
from aqt import AnkiQt, gui_hooks, is_mac
|
||||
from aqt.qt import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
|
@ -81,10 +81,15 @@ class Previewer(QDialog):
|
|||
qconnect(self.finished, self._on_finished)
|
||||
self.silentlyClose = True
|
||||
self.vbox = QVBoxLayout()
|
||||
spacing = 6
|
||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.vbox.setSpacing(spacing)
|
||||
self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER)
|
||||
self.vbox.addWidget(self._web)
|
||||
self.bbox = QDialogButtonBox()
|
||||
self.bbox.setContentsMargins(
|
||||
spacing, spacing if is_mac else 0, spacing, spacing
|
||||
)
|
||||
self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
|
||||
|
||||
gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER)
|
||||
|
|
|
@ -151,6 +151,7 @@ class Editor:
|
|||
self.add_webview()
|
||||
self.setupWeb()
|
||||
self.setupShortcuts()
|
||||
self.setupColourPalette()
|
||||
gui_hooks.editor_did_init(self)
|
||||
|
||||
# Initial setup
|
||||
|
@ -349,6 +350,14 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
|||
keys, fn, _ = row
|
||||
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
|
||||
|
||||
def setupColourPalette(self) -> None:
|
||||
if not (colors := self.mw.col.get_config("customColorPickerPalette")):
|
||||
return
|
||||
for i, colour in enumerate(colors[: QColorDialog.customCount()]):
|
||||
if not QColor.isValidColorName(colour):
|
||||
continue
|
||||
QColorDialog.setCustomColor(i, QColor.fromString(colour))
|
||||
|
||||
def _addFocusCheck(self, fn: Callable) -> Callable:
|
||||
def checkFocus() -> None:
|
||||
if self.currentField is None:
|
||||
|
|
|
@ -599,6 +599,15 @@ def deck_options_ready() -> bytes:
|
|||
return b""
|
||||
|
||||
|
||||
def save_custom_colours() -> bytes:
|
||||
colors = [
|
||||
QColorDialog.customColor(i).name(QColor.NameFormat.HexArgb)
|
||||
for i in range(QColorDialog.customCount())
|
||||
]
|
||||
aqt.mw.col.set_config("customColorPickerPalette", colors)
|
||||
return b""
|
||||
|
||||
|
||||
post_handler_list = [
|
||||
congrats_info,
|
||||
get_deck_configs_for_update,
|
||||
|
@ -614,6 +623,7 @@ post_handler_list = [
|
|||
search_in_browser,
|
||||
deck_options_require_close,
|
||||
deck_options_ready,
|
||||
save_custom_colours,
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import aqt.browser
|
|||
import aqt.operations
|
||||
from anki.cards import Card, CardId
|
||||
from anki.collection import Config, OpChanges, OpChangesWithCount
|
||||
from anki.lang import with_collapsed_whitespace
|
||||
from anki.scheduler.base import ScheduleCardsAsNew
|
||||
from anki.scheduler.v3 import (
|
||||
CardAnswer,
|
||||
|
@ -966,11 +967,15 @@ timerStopped = false;
|
|||
elapsed = self.mw.col.timeboxReached()
|
||||
if elapsed:
|
||||
assert not isinstance(elapsed, bool)
|
||||
part1 = tr.studying_card_studied_in(count=elapsed[1])
|
||||
mins = int(round(elapsed[0] / 60))
|
||||
part2 = tr.studying_minute(count=mins)
|
||||
cards_val = elapsed[1]
|
||||
minutes_val = int(round(elapsed[0] / 60))
|
||||
message = with_collapsed_whitespace(
|
||||
tr.studying_card_studied_in_minute(
|
||||
cards=cards_val, minutes=str(minutes_val)
|
||||
)
|
||||
)
|
||||
fin = tr.studying_finish()
|
||||
diag = askUserDialog(f"{part1} {part2}", [tr.studying_continue(), fin])
|
||||
diag = askUserDialog(message, [tr.studying_continue(), fin])
|
||||
diag.setIcon(QMessageBox.Icon.Information)
|
||||
if diag.run() == fin:
|
||||
self.mw.moveToState("deckBrowser")
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -174,7 +174,7 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
|
||||
let health_check_passed = if health_check {
|
||||
let health_check_passed = if health_check && input.train_set.len() > 300 {
|
||||
let fsrs = FSRS::new(None)?;
|
||||
fsrs.evaluate_with_time_series_splits(input, |_| true)
|
||||
.ok()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
-->
|
||||
<script lang="ts">
|
||||
import Shortcut from "$lib/components/Shortcut.svelte";
|
||||
import { saveCustomColours } from "@generated/backend";
|
||||
|
||||
export let keyCombination: string | null = null;
|
||||
export let value: string;
|
||||
|
@ -11,7 +12,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
let inputRef: HTMLInputElement;
|
||||
</script>
|
||||
|
||||
<input bind:this={inputRef} tabindex="-1" type="color" bind:value on:input on:change />
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
tabindex="-1"
|
||||
type="color"
|
||||
bind:value
|
||||
on:input
|
||||
on:change
|
||||
on:click={() => saveCustomColours({})}
|
||||
/>
|
||||
|
||||
{#if keyCombination}
|
||||
<Shortcut {keyCombination} on:action={() => inputRef.click()} />
|
||||
|
|
|
@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import WithColorHelper from "./WithColorHelper.svelte";
|
||||
import { saveCustomColours } from "@generated/backend";
|
||||
|
||||
export let color: string;
|
||||
|
||||
|
@ -134,7 +135,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
color = setColor(event);
|
||||
bridgeCommand(`lastHighlightColor:${color}`);
|
||||
}}
|
||||
on:change={() => setTextColor()}
|
||||
on:change={() => {
|
||||
setTextColor();
|
||||
saveCustomColours({});
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</WithColorHelper>
|
||||
|
|
|
@ -22,6 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import WithColorHelper from "./WithColorHelper.svelte";
|
||||
import { saveCustomColours } from "@generated/backend";
|
||||
|
||||
export let color: string;
|
||||
|
||||
|
@ -158,6 +159,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
setTimeout(() => {
|
||||
setTextColor();
|
||||
}, 200);
|
||||
saveCustomColours({});
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
|
|
|
@ -10,6 +10,9 @@ export function allImagesLoaded(): Promise<void[]> {
|
|||
}
|
||||
|
||||
function imageLoaded(img: HTMLImageElement): Promise<void> {
|
||||
if (!img.getAttribute("decoding")) {
|
||||
img.decoding = "async";
|
||||
}
|
||||
return img.complete
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
|
|
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>
|
||||
|
|
|
@ -6939,8 +6939,8 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"vite@npm:6":
|
||||
version: 6.3.5
|
||||
resolution: "vite@npm:6.3.5"
|
||||
version: 6.3.6
|
||||
resolution: "vite@npm:6.3.6"
|
||||
dependencies:
|
||||
esbuild: "npm:^0.25.0"
|
||||
fdir: "npm:^6.4.4"
|
||||
|
@ -6989,7 +6989,7 @@ __metadata:
|
|||
optional: true
|
||||
bin:
|
||||
vite: bin/vite.js
|
||||
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c
|
||||
checksum: 10c0/add701f1e72596c002275782e38d0389ab400c1be330c93a3009804d62db68097a936ca1c53c3301df3aaacfe5e328eab547060f31ef9c49a277ae50df6ad4fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
Loading…
Reference in a new issue