Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ detail, which is where the reasoning lives.

### Added

- **Review Activity heatmap.** The progress screen shows up to 12 weeks of
reviews by local day, with Monday–Sunday rows and five shading levels that
also work without colour. Undone answers are excluded, future days stay
blank, and narrower terminals show fewer weeks. The calendar also appears
when there is no review history yet.

- **Cloze deletion.** Wrap a word in `{{braces}}` and the card becomes a
sentence with a hole in it:

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ that asks, not the side that answers.
* **Works with the sync tool you already have** — Decks are plain text and saves are atomic, so Syncthing, Dropbox, `rsync` or git sync a deck between machines with no support needed from FlashTerm. And when two machines review before they sync, `--absorb-conflicts` merges the conflict copy your sync tool left behind back into the review log and puts the scheduling it recorded back on the cards. See [Syncing Between Machines](#syncing-between-machines).
* **Cloze deletion** — Wrap a word in `{{braces}}` and the card becomes a sentence with a hole in it: `The {{mitochondrion}} is the powerhouse of the cell` asks `The [...] is the powerhouse of the cell`. The answer column is empty, because the answers are inside the question — so a deck of them is a file of bare sentences. Anki's syntax, including numbered blanks and per-blank hints, so decks paste across in both directions. A sentence with several holes is asked one hole at a time and scheduled *once*, on the worst of the answers. See [Cloze Deletion](#cloze-deletion).
* **Images** — A card can name a picture in the deck's eleventh column, drawn inside the card frame. Terminals that speak the kitty graphics protocol (kitty, Ghostty) need nothing installed at all; everything else draws it as coloured text blocks via [chafa](https://hpjansson.org/chafa/), which works even over `ssh` and inside `tmux`. Aspect ratio is preserved and the picture is fitted to the frame, so a panorama and a portrait both land inside the borders. A deck of pictures still reviews as plain text anywhere that cannot draw them. See [Images](#images).
* **Deck statistics** — Success rates, review counts, a box-by-box mastery breakdown with ASCII bars, automatic flagging of your hardest card, and how much you reviewed today alongside your current daily streak.
* **Deck statistics** — Success rates, review counts, a box-by-box mastery breakdown with ASCII bars, automatic flagging of your hardest card, and how much you reviewed today alongside your current daily streak. The Review Activity heatmap shows up to 12 weeks of daily reviews, with weeks running left to right and Monday–Sunday rows. The calendar appears even before your first review. Shading counts all answers (including hints and mistakes), excludes undone answers, and stays readable without colour. Dates use local time; only logged reviews appear, so older card counters cannot fill in missing history.
* **Review log** — Every answer is appended to a `deck.txt.log` beside the deck: what was asked, which way round, whether you got it, and when, to the second. The card counters say what a card's state *is*; the log says what actually happened, which is what streaks, retention over time and merging two machines' reviews all need. It is append-only, so it never rewrites history and never conflicts.
* **Single-keypress menus** — `2` enters review; no Enter, no waiting. Every screen that takes a key shows a legend of what the keys do. Guarded on `isatty`, so piped input still reads whole lines and every script, pipeline and recording keeps working unchanged. `Ctrl+C` at a menu saves and exits cleanly rather than killing the process.
* **Framed cards** — The card under review is drawn in a box, centred in the window, with long questions wrapped to fit. Widths are measured in terminal columns rather than bytes, so the border still lines up on Japanese or accented cards — which is exactly where most tools get it wrong.
Expand All @@ -153,7 +153,7 @@ that asks, not the side that answers.
| `1` Add flashcard | Question, answer and semicolon-separated tags. Use `\|` for alternative answers. |
| `2` Review flashcards | Pick a mode: **due** cards (most overdue first), **all** (shuffled), by **tag**, **difficult** only (incorrect > correct), or by **box**. Then pick a direction: Enter for normal, `r` to be shown the answer and type the question. |
| `3` Manage flashcards | List, edit, delete or **find** cards. Editing and deleting ask for a search term first, so you never scroll a 200-card list to reach one card. Numbers shown are deck positions, and a card the search did not list cannot be edited or deleted by number. |
| `4` Display progress | Deck statistics, due counts, reviews today, daily streak, box distribution, hardest card, per-card rates. |
| `4` Display progress | Deck statistics, due counts, reviews today, daily streak, review activity heatmap, box distribution, hardest card, per-card rates. |
| `5` Import flashcards | Append cards from a `.csv` file. |
| `6` Export flashcards | Write the deck to `.csv`, review history included, so it re-imports without losing progress. |
| `7` List unique tags | Every tag in the deck, sorted, with card counts. |
Expand Down
7 changes: 3 additions & 4 deletions src/event.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,12 @@ LogStats summarize(const std::vector<ReviewEvent>& events, int today_days) {
const std::set<std::string> undone = undone_ids(events);

LogStats stats;
std::set<int> active_days;
for (const auto& event : events) {
if (event.is_undo() || undone.count(event.id) > 0) continue;

const int day = local_day_of(event.timestamp);
if (day == kNoDate) continue;
active_days.insert(day);
++stats.reviews_by_day[day];

if (day == today_days) {
++stats.reviewed_today;
Expand All @@ -341,8 +340,8 @@ LogStats summarize(const std::vector<ReviewEvent>& events, int today_days) {

// Today is still in progress, so an empty today does not end a streak that
// ran through yesterday. Two empty days in a row does.
int day = (active_days.count(today_days) > 0) ? today_days : today_days - 1;
while (active_days.count(day) > 0) {
int day = (stats.reviews_by_day.count(today_days) > 0) ? today_days : today_days - 1;
while (stats.reviews_by_day.count(day) > 0) {
++stats.current_streak;
--day;
}
Expand Down
2 changes: 2 additions & 0 deletions src/event.h
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ std::vector<ReviewEvent> merge_events(const std::vector<ReviewEvent>& a,
std::map<std::string, CardState> replay(const std::vector<ReviewEvent>& events);

struct LogStats {
// Completed answers per local calendar day, excluding undone answers.
std::map<int, int> reviews_by_day;
int reviewed_today = 0;
int correct_today = 0;
// Answers that needed the hint. Counted separately because "got it, but only
Expand Down
40 changes: 40 additions & 0 deletions src/ui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <algorithm>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <vector>

Expand Down Expand Up @@ -395,6 +396,43 @@ void list_unique_tags(const Deck& deck) {
std::cout << "\n";
}

std::string contribution_heatmap(const LogStats& stats, int today_days,
int width) {
std::ostringstream out;
out << color::cyan << "--- Review Activity ---\n" << color::reset;

const int weeks = std::clamp((width - 6) / 2, 1, 12);
// 1970-01-01 was Thursday. Normalize the remainder for pre-epoch dates.
const int weekday = ((today_days % 7 + 3) % 7 + 7) % 7;
const int start = today_days - weekday - (weeks - 1) * 7;
out << " " << format_date(start) << " to " << format_date(today_days)
<< "\n";
const char* days[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
for (int row = 0; row < 7; ++row) {
out << " " << days[row] << " ";
for (int week = 0; week < weeks; ++week) {
if (week > 0) out << " ";
const int day = start + week * 7 + row;
if (day > today_days) {
out << " ";
continue;
}
const auto found = stats.reviews_by_day.find(day);
const int count = found == stats.reviews_by_day.end() ? 0 : found->second;
const char* cell = count == 0 ? "·" : count < 5 ? "░" : count < 10 ? "▒"
: count < 20 ? "▓" : "█";
if (count > 0) out << color::green;
out << cell;
if (count > 0) out << color::reset;
}
out << "\n";
}
out << " Reviews/day (local time):\n"
<< " · 0 ░ 1–4 ▒ 5–9\n"
<< " ▓ 10–19 █ 20+\n\n";
return out.str();
}

void display_progress(const Deck& deck) {
if (deck.empty()) {
std::cout << color::yellow << "No flashcards to display progress for.\n\n"
Expand Down Expand Up @@ -432,6 +470,8 @@ void display_progress(const Deck& deck) {
}
std::cout << "\n";

std::cout << contribution_heatmap(log_stats, today_days, terminal_width());

std::cout << color::cyan << "--- Leitner Box Distribution ---\n"
<< color::reset;
for (int box = 1; box <= kMaxBox; ++box) {
Expand Down
4 changes: 4 additions & 0 deletions src/ui.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ void add_flashcard(Deck& deck);
void manage_flashcards(Deck& deck);
void list_flashcards(const Deck& deck);
void list_unique_tags(const Deck& deck);
// Monday-first calendar, oldest week on the left, ending with this week.
// Width limits the number of weeks (up to 12); future days are blank.
std::string contribution_heatmap(const LogStats& stats, int today_days,
int width);
void display_progress(const Deck& deck);
void print_help();
} // namespace FlashTerm
13 changes: 13 additions & 0 deletions tests/golden/cases/progress/expected
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ Loaded 2 flashcards from deck.txt
Reviewed Today: 0 (0 Correct, 0 Incorrect)
Current Streak: 0 days

--- Review Activity ---
<DATE1> to <DATE2>
Mon · · · · · · · · · · · <DAY>
Tue · · · · · · · · · · · <DAY>
Wed · · · · · · · · · · · <DAY>
Thu · · · · · · · · · · · <DAY>
Fri · · · · · · · · · · · <DAY>
Sat · · · · · · · · · · · <DAY>
Sun · · · · · · · · · · · <DAY>
Reviews/day (local time):
· 0 ░ 1–4 ▒ 5–9
▓ 10–19 █ 20+

--- Leitner Box Distribution ---
Box 1 (Weakest): [██████████] 2 cards (100%), every 1d
Box 2: [░░░░░░░░░░] 0 cards (0%), every 3d
Expand Down
2 changes: 1 addition & 1 deletion tests/golden/cases/review-cloze-undo/deck.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
The Earth orbits the {{c1::Sun}} once every {{c2::365}} days,,astronomy,2,1,3,2026-08-01,2026-08-08
The Earth orbits the {{c1::Sun}} once every {{c2::365}} days,,astronomy,2,1,3
8 changes: 4 additions & 4 deletions tests/golden/cases/review-cloze-undo/expected
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Loaded 1 flashcards from deck.txt
> Progress: [████████████████████] 100% (1/1 cards)

┌──────────────────────────────────────────────────────────────┐
│ Box 3 · overdue by 19 days · astronomy · blank 1 of 2 │
│ Box 3 · new · astronomy · blank 1 of 2
├──────────────────────────────────────────────────────────────┤
│ │
│ The Earth orbits the [...] once every 365 days │
Expand All @@ -38,7 +38,7 @@ Loaded 1 flashcards from deck.txt
Your answer: Progress: [████████████████████] 100% (1/1 cards)

┌──────────────────────────────────────────────────────────────┐
│ Box 3 · overdue by 19 days · astronomy · blank 2 of 2 │
│ Box 3 · new · astronomy · blank 2 of 2
├──────────────────────────────────────────────────────────────┤
│ │
│ The Earth orbits the Sun once every [...] days │
Expand All @@ -62,7 +62,7 @@ Next review in 1 day (<DATE1>).
> Progress: [████████████████████] 100% (1/1 cards)

┌──────────────────────────────────────────────────────────────┐
│ Box 3 · overdue by 19 days · astronomy · blank 1 of 2 │
│ Box 3 · new · astronomy · blank 1 of 2
├──────────────────────────────────────────────────────────────┤
│ │
│ The Earth orbits the [...] once every 365 days │
Expand All @@ -73,7 +73,7 @@ Next review in 1 day (<DATE1>).
Your answer: Progress: [████████████████████] 100% (1/1 cards)

┌──────────────────────────────────────────────────────────────┐
│ Box 3 · overdue by 19 days · astronomy · blank 2 of 2 │
│ Box 3 · new · astronomy · blank 2 of 2
├──────────────────────────────────────────────────────────────┤
│ │
│ The Earth orbits the Sun once every [...] days │
Expand Down
6 changes: 6 additions & 0 deletions tests/golden/normalise.awk
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ function collapse_cursor(line, out, token, n) {

{
line = $0
# Only the last week changes with the weekday of the test run: its future
# cells are blank. Fixed-date unit tests check those cells and their shading.
if (line == "--- Review Activity ---") heatmap = 1
if (heatmap && line ~ /^ (Mon|Tue|Wed|Thu|Fri|Sat|Sun) /)
sub(/(·|░|▒|▓|█| )$/, "<DAY>", line)
if (line == " Reviews/day (local time):") heatmap = 0
line = collapse_graphics(line)
line = collapse_cursor(line)
gsub(/\r/, "<CR>", line)
Expand Down
47 changes: 47 additions & 0 deletions tests/tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,9 @@ void test_summarize() {
EXPECT_EQ(stats.reviewed_today, 3);
EXPECT_EQ(stats.correct_today, 2);
EXPECT_EQ(stats.current_streak, 3);
EXPECT_EQ(stats.reviews_by_day.at(today_days), 3);
EXPECT_EQ(stats.reviews_by_day.at(today_days - 1), 1);
EXPECT_EQ(stats.reviews_by_day.count(today_days - 3), 0u);

// A gap ends the streak: the run before it is history, not part of today's.
events.push_back(answer_event("card2", stamp_on_day(today_days - 4), true, 1, 2));
Expand Down Expand Up @@ -2309,10 +2312,52 @@ void test_summarize() {
stats = summarize(events, today_days);
EXPECT_EQ(stats.reviewed_today, 2);
EXPECT_EQ(stats.correct_today, 1);
EXPECT_EQ(stats.reviews_by_day.at(today_days), 2);

ReviewEvent invalid = answer_event("card1", "nonsense", true, 1, 2);
events.push_back(invalid);
EXPECT_TRUE(summarize(events, today_days).reviews_by_day == stats.reviews_by_day);

stats = summarize({}, today_days);
EXPECT_EQ(stats.reviewed_today, 0);
EXPECT_EQ(stats.current_streak, 0);
EXPECT_TRUE(stats.reviews_by_day.empty());
}

void test_contribution_heatmap() {
const int day = parse_date("2024-03-01"); // Friday, following leap day.
LogStats stats;
const std::string empty = contribution_heatmap(stats, day, 10);
EXPECT_TRUE(empty.find("--- Review Activity ---\n") != std::string::npos);
EXPECT_TRUE(empty.find("2024-02-19 to 2024-03-01") != std::string::npos);
EXPECT_TRUE(empty.find(" Mon · ·\n") != std::string::npos);
EXPECT_TRUE(empty.find(" Fri · ·\n") != std::string::npos);
EXPECT_TRUE(empty.find(" Sat · \n") != std::string::npos);
EXPECT_TRUE(empty.find(" Sun · \n") != std::string::npos);
stats.reviews_by_day = {{day - 4, 1}, {day - 3, 4}, {day - 2, 5},
{day - 1, 10}, {day, 20}, {day + 1, 99},
{day - 7, 9}, {day - 80, 20}};
const std::string chart = contribution_heatmap(stats, day, 10);
EXPECT_TRUE(chart.find("2024-02-19 to 2024-03-01") != std::string::npos);
EXPECT_TRUE(chart.find(" Mon · ░\n") != std::string::npos);
EXPECT_TRUE(chart.find(" Tue · ░\n") != std::string::npos);
EXPECT_TRUE(chart.find(" Wed · ▒\n") != std::string::npos);
EXPECT_TRUE(chart.find(" Thu · ▓\n") != std::string::npos);
EXPECT_TRUE(chart.find(" Fri ▒ █\n") != std::string::npos);
EXPECT_TRUE(chart.find(" Sat · \n") != std::string::npos);
EXPECT_TRUE(chart.find(" Sun · \n") != std::string::npos);
EXPECT_TRUE(contribution_heatmap(stats, day, 80).find("2023-12-11 to 2024-03-01")
!= std::string::npos);
const int sunday = parse_date("2024-03-03");
stats.reviews_by_day[sunday] = 19;
EXPECT_TRUE(contribution_heatmap(stats, sunday, 8).find(" Sun ▓\n")
!= std::string::npos);
const int monday = parse_date("2024-03-04");
EXPECT_TRUE(contribution_heatmap(stats, monday, 8).find(" Tue \n")
!= std::string::npos);
stats.reviews_by_day = {{-1, 1}}; // Wednesday before the epoch.
EXPECT_TRUE(contribution_heatmap(stats, -1, 8).find(" Wed ░\n")
!= std::string::npos);
}

void test_card_ids() {
Expand Down Expand Up @@ -2575,6 +2620,7 @@ void test_partial_events() {
EXPECT_EQ(stats.reviewed_today, 2);
EXPECT_EQ(stats.correct_today, 1);
EXPECT_EQ(stats.hinted_today, 1);
EXPECT_EQ(stats.reviews_by_day.at(today_days), 2);
}

void test_review_keys_do_not_shadow_answers() {
Expand Down Expand Up @@ -2717,6 +2763,7 @@ int main() {
test_card_image_column();
test_shipped_example_decks();
test_summarize();
test_contribution_heatmap();
test_card_ids();
test_save_leaves_an_unchanged_deck_alone();
test_wrap();
Expand Down
Loading