Compare commits

...

13 commits

Author SHA1 Message Date
Thomas Rixen
8a8e6c5429
Merge b5e7c7c6f0 into 3890e12c9e 2025-09-17 15:23:46 +08:00
Damien Elmes
3890e12c9e Bump version
.1 release skipped due to missing bugfix
2025-09-17 16:50:13 +10:00
llama
80cff16250
fix: persist colour picker's custom palette in profile (#4326)
* add SaveCustomColours rpc method

* restore custom colour palette on editor init

* save custom colour palette on colour picker open and input

there doesn't seem to be an event fired when the picker is
cancelled/closed, so it's still possible for work to be lost

* save colours on `change` instead of `input`

`input` is supposed to be fired on every adjustment to the picker
whereas `change` is only fired when the picker is accepted, but qt
seems to treat both as the latter, so this is currently a no-op

* Store colors in the collection

One minor tweak to the logic while I was there: an invalid color no
longer invalidates all the rest.

---------

Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2025-09-17 16:46:53 +10:00
Abdo
75d9026be5
Decode images asynchronously (#4320) 2025-09-17 09:06:42 +03:00
Damien Elmes
6854d13b88 Bump version 2025-09-17 15:50:16 +10:00
Damien Elmes
29072654db Update translations 2025-09-17 15:50:02 +10:00
jcznk
ec6f09958a
(UI polish) Improved margins in Card Browser's "Previewer" (#4337)
* Improved margins in Card Browser's "Preview" pane

* Alternate approach that looks good on Mac too

---------

Co-authored-by: Damien Elmes <gpg@ankiweb.net>
Co-authored-by: Damien Elmes <dae@users.noreply.github.com>
2025-09-17 15:30:22 +10:00
snowtimeglass
c2957746f4
Make timebox message translatable with flexible variable order (#4338)
* Make timebox message translatable with flexible variable order

Currently, the timebox dialog message is built from two separate strings,
each containing one variable:
"{ $count } cards studied in" + "{ $count } minutes."

As a result, translators cannot freely reorder the variables in their translations.

This change introduces a single string with both variables, allowing translators
to adjust the order for more natural expressions in their languages.

* Preserve old string for now

* Ensure message doesn't display over two lines

---------

Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2025-09-17 15:13:59 +10:00
Luc Mcgrady
9e415869b8
Fix/Add lower review limit to health check. (#4334) 2025-09-17 14:04:27 +10:00
Emil Hamrin
7e8a1076c1
Updated Dockerfile to use Ninja build system (#4321)
* Updated Dockerfile to support ninja build

* Install python using uv

* Bumped python version

* Add disclaimer (dae)
2025-09-17 14:02:09 +10:00
dependabot[bot]
b97fb45e06
Bump vite from 6.3.5 to 6.3.6 (#4328)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-17 12:08:11 +10:00
Damien Elmes
b5e7c7c6f0
Merge branch 'main' into main 2025-08-20 01:17:43 +10:00
Thomas Rixen
6f8e7fa722 Congratulations Screen with 7-Day future due graph 2025-08-18 01:07:21 +02:00
21 changed files with 406 additions and 36 deletions

View file

@ -1 +1 @@
25.09 25.09.2

View file

@ -49,6 +49,7 @@ Sander Santema <github.com/sandersantema/>
Thomas Brownback <https://github.com/brownbat/> Thomas Brownback <https://github.com/brownbat/>
Andrew Gaul <andrew@gaul.org> Andrew Gaul <andrew@gaul.org>
kenden kenden
Emil Hamrin <github.com/e-hamrin>
Nickolay Yudin <kelciour@gmail.com> Nickolay Yudin <kelciour@gmail.com>
neitrinoweb <github.com/neitrinoweb/> neitrinoweb <github.com/neitrinoweb/>
Andreas Reis <github.com/nwwt> Andreas Reis <github.com/nwwt>

View file

@ -1,35 +1,78 @@
# This Dockerfile uses three stages. # This is a user-contributed Dockerfile. No official support is available.
# 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.
ARG PYTHON_VERSION="3.9"
ARG DEBIAN_FRONTEND="noninteractive" ARG DEBIAN_FRONTEND="noninteractive"
# Build anki. FROM ubuntu:24.04 AS build
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
WORKDIR /opt/anki WORKDIR /opt/anki
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 . . COPY . .
# Build python wheels.
RUN ./tools/build RUN ./tools/build
# Install pre-compiled Anki. # Install pre-compiled Anki.
FROM python:${PYTHON_VERSION}-slim as installer FROM python:3.13-slim AS installer
WORKDIR /opt/anki/ WORKDIR /opt/anki/
COPY --from=build /opt/anki/wheels/ wheels/ COPY --from=build /opt/anki/out/wheels/ wheels/
# Use virtual environment. # Use virtual environment.
RUN python -m venv venv \ 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 setuptools wheel \
&& ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl && ./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. # 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 COPY --from=installer /opt/anki/venv /opt/anki/venv
ENV PATH=/opt/anki/venv/bin:$PATH ENV PATH=/opt/anki/venv/bin:$PATH
# Install run-time dependencies. # Install run-time dependencies.
@ -59,9 +102,9 @@ RUN apt-get update \
libxrender1 \ libxrender1 \
libxtst6 \ libxtst6 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add non-root user. # Add non-root user.
RUN useradd --create-home anki RUN useradd --create-home anki
USER anki USER anki
WORKDIR /work WORKDIR /work
ENTRYPOINT ["/opt/anki/venv/bin/anki"] ENTRYPOINT ["/opt/anki/venv/bin/anki"]
LABEL maintainer="Jakub Kaczmarzyk <jakub.kaczmarzyk@gmail.com>"

@ -1 +1 @@
Subproject commit 60bd4d4834b57b85915f52eb978935ff6b8c1425 Subproject commit 480ef0da728c7ea3485c58529ae7ee02be3e5dba

View file

@ -46,6 +46,20 @@ studying-type-answer-unknown-field = Type answer: unknown field { $val }
studying-unbury = Unbury studying-unbury = Unbury
studying-what-would-you-like-to-unbury = What would you like to 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-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 = studying-card-studied-in =
{ $count -> { $count ->
[one] { $count } card studied in [one] { $count } card studied in
@ -56,5 +70,3 @@ studying-minute =
[one] { $count } minute. [one] { $count } minute.
*[other] { $count } minutes. *[other] { $count } minutes.
} }
studying-question-time-elapsed = Question time elapsed
studying-answer-time-elapsed = Answer time elapsed

View file

@ -27,6 +27,9 @@ service FrontendService {
rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty); rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);
// Warns python that the deck option web view is ready to receive requests. // Warns python that the deck option web view is ready to receive requests.
rpc deckOptionsReady(generic.Empty) returns (generic.Empty); rpc deckOptionsReady(generic.Empty) returns (generic.Empty);
// Save colour picker's custom colour palette
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
} }
service BackendFrontendService {} service BackendFrontendService {}

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

@ -13,7 +13,7 @@ import aqt.browser
from anki.cards import Card from anki.cards import Card
from anki.collection import Config from anki.collection import Config
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks, is_mac
from aqt.qt import ( from aqt.qt import (
QCheckBox, QCheckBox,
QDialog, QDialog,
@ -81,10 +81,15 @@ class Previewer(QDialog):
qconnect(self.finished, self._on_finished) qconnect(self.finished, self._on_finished)
self.silentlyClose = True self.silentlyClose = True
self.vbox = QVBoxLayout() self.vbox = QVBoxLayout()
spacing = 6
self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setContentsMargins(0, 0, 0, 0)
self.vbox.setSpacing(spacing)
self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER) self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER)
self.vbox.addWidget(self._web) self.vbox.addWidget(self._web)
self.bbox = QDialogButtonBox() self.bbox = QDialogButtonBox()
self.bbox.setContentsMargins(
spacing, spacing if is_mac else 0, spacing, spacing
)
self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight) self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER) gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER)

View file

@ -151,6 +151,7 @@ class Editor:
self.add_webview() self.add_webview()
self.setupWeb() self.setupWeb()
self.setupShortcuts() self.setupShortcuts()
self.setupColourPalette()
gui_hooks.editor_did_init(self) gui_hooks.editor_did_init(self)
# Initial setup # Initial setup
@ -349,6 +350,14 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
keys, fn, _ = row keys, fn, _ = row
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore 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 _addFocusCheck(self, fn: Callable) -> Callable:
def checkFocus() -> None: def checkFocus() -> None:
if self.currentField is None: if self.currentField is None:

View file

@ -599,6 +599,15 @@ def deck_options_ready() -> bytes:
return b"" 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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -614,6 +623,7 @@ post_handler_list = [
search_in_browser, search_in_browser,
deck_options_require_close, deck_options_require_close,
deck_options_ready, deck_options_ready,
save_custom_colours,
] ]

View file

@ -17,6 +17,7 @@ import aqt.browser
import aqt.operations import aqt.operations
from anki.cards import Card, CardId from anki.cards import Card, CardId
from anki.collection import Config, OpChanges, OpChangesWithCount from anki.collection import Config, OpChanges, OpChangesWithCount
from anki.lang import with_collapsed_whitespace
from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.base import ScheduleCardsAsNew
from anki.scheduler.v3 import ( from anki.scheduler.v3 import (
CardAnswer, CardAnswer,
@ -966,11 +967,15 @@ timerStopped = false;
elapsed = self.mw.col.timeboxReached() elapsed = self.mw.col.timeboxReached()
if elapsed: if elapsed:
assert not isinstance(elapsed, bool) assert not isinstance(elapsed, bool)
part1 = tr.studying_card_studied_in(count=elapsed[1]) cards_val = elapsed[1]
mins = int(round(elapsed[0] / 60)) minutes_val = int(round(elapsed[0] / 60))
part2 = tr.studying_minute(count=mins) message = with_collapsed_whitespace(
tr.studying_card_studied_in_minute(
cards=cards_val, minutes=str(minutes_val)
)
)
fin = tr.studying_finish() 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) diag.setIcon(QMessageBox.Icon.Information)
if diag.run() == fin: if diag.run() == fin:
self.mw.moveToState("deckBrowser") self.mw.moveToState("deckBrowser")

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

@ -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)?; let fsrs = FSRS::new(None)?;
fsrs.evaluate_with_time_series_splits(input, |_| true) fsrs.evaluate_with_time_series_splits(input, |_| true)
.ok() .ok()

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

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import Shortcut from "$lib/components/Shortcut.svelte"; import Shortcut from "$lib/components/Shortcut.svelte";
import { saveCustomColours } from "@generated/backend";
export let keyCombination: string | null = null; export let keyCombination: string | null = null;
export let value: string; 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; let inputRef: HTMLInputElement;
</script> </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} {#if keyCombination}
<Shortcut {keyCombination} on:action={() => inputRef.click()} /> <Shortcut {keyCombination} on:action={() => inputRef.click()} />

View file

@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ColorPicker from "./ColorPicker.svelte"; import ColorPicker from "./ColorPicker.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte"; import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import WithColorHelper from "./WithColorHelper.svelte"; import WithColorHelper from "./WithColorHelper.svelte";
import { saveCustomColours } from "@generated/backend";
export let color: string; 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); color = setColor(event);
bridgeCommand(`lastHighlightColor:${color}`); bridgeCommand(`lastHighlightColor:${color}`);
}} }}
on:change={() => setTextColor()} on:change={() => {
setTextColor();
saveCustomColours({});
}}
/> />
</IconButton> </IconButton>
</WithColorHelper> </WithColorHelper>

View file

@ -22,6 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ColorPicker from "./ColorPicker.svelte"; import ColorPicker from "./ColorPicker.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte"; import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import WithColorHelper from "./WithColorHelper.svelte"; import WithColorHelper from "./WithColorHelper.svelte";
import { saveCustomColours } from "@generated/backend";
export let color: string; export let color: string;
@ -158,6 +159,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setTimeout(() => { setTimeout(() => {
setTextColor(); setTextColor();
}, 200); }, 200);
saveCustomColours({});
}} }}
/> />
</IconButton> </IconButton>

View file

@ -10,6 +10,9 @@ export function allImagesLoaded(): Promise<void[]> {
} }
function imageLoaded(img: HTMLImageElement): Promise<void> { function imageLoaded(img: HTMLImageElement): Promise<void> {
if (!img.getAttribute("decoding")) {
img.decoding = "async";
}
return img.complete return img.complete
? Promise.resolve() ? Promise.resolve()
: new Promise((resolve) => { : new Promise((resolve) => {

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>

View file

@ -6939,8 +6939,8 @@ __metadata:
linkType: hard linkType: hard
"vite@npm:6": "vite@npm:6":
version: 6.3.5 version: 6.3.6
resolution: "vite@npm:6.3.5" resolution: "vite@npm:6.3.6"
dependencies: dependencies:
esbuild: "npm:^0.25.0" esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4" fdir: "npm:^6.4.4"
@ -6989,7 +6989,7 @@ __metadata:
optional: true optional: true
bin: bin:
vite: bin/vite.js vite: bin/vite.js
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c checksum: 10c0/add701f1e72596c002275782e38d0389ab400c1be330c93a3009804d62db68097a936ca1c53c3301df3aaacfe5e328eab547060f31ef9c49a277ae50df6ad4fb
languageName: node languageName: node
linkType: hard linkType: hard