diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c2df..4758fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index 5567507..477221e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. | diff --git a/src/event.cpp b/src/event.cpp index d609301..a3d79d6 100644 --- a/src/event.cpp +++ b/src/event.cpp @@ -324,13 +324,12 @@ LogStats summarize(const std::vector& events, int today_days) { const std::set undone = undone_ids(events); LogStats stats; - std::set 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; @@ -341,8 +340,8 @@ LogStats summarize(const std::vector& 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; } diff --git a/src/event.h b/src/event.h index 6004db0..6cc0239 100644 --- a/src/event.h +++ b/src/event.h @@ -131,6 +131,8 @@ std::vector merge_events(const std::vector& a, std::map replay(const std::vector& events); struct LogStats { + // Completed answers per local calendar day, excluding undone answers. + std::map reviews_by_day; int reviewed_today = 0; int correct_today = 0; // Answers that needed the hint. Counted separately because "got it, but only diff --git a/src/ui.cpp b/src/ui.cpp index 59e4378..47e0a21 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -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" @@ -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) { diff --git a/src/ui.h b/src/ui.h index 04ce08a..7ff1abe 100644 --- a/src/ui.h +++ b/src/ui.h @@ -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 diff --git a/tests/golden/cases/progress/expected b/tests/golden/cases/progress/expected index b661f9e..1b1e7a6 100644 --- a/tests/golden/cases/progress/expected +++ b/tests/golden/cases/progress/expected @@ -22,6 +22,19 @@ Loaded 2 flashcards from deck.txt Reviewed Today: 0 (0 Correct, 0 Incorrect) Current Streak: 0 days +--- Review Activity --- + to + Mon · · · · · · · · · · · + Tue · · · · · · · · · · · + Wed · · · · · · · · · · · + Thu · · · · · · · · · · · + Fri · · · · · · · · · · · + Sat · · · · · · · · · · · + Sun · · · · · · · · · · · + 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 diff --git a/tests/golden/cases/review-cloze-undo/deck.txt b/tests/golden/cases/review-cloze-undo/deck.txt index ebf6d01..d77d791 100644 --- a/tests/golden/cases/review-cloze-undo/deck.txt +++ b/tests/golden/cases/review-cloze-undo/deck.txt @@ -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 diff --git a/tests/golden/cases/review-cloze-undo/expected b/tests/golden/cases/review-cloze-undo/expected index c528dbf..2180311 100644 --- a/tests/golden/cases/review-cloze-undo/expected +++ b/tests/golden/cases/review-cloze-undo/expected @@ -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 │ @@ -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 │ @@ -62,7 +62,7 @@ Next review in 1 day (). > 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 │ @@ -73,7 +73,7 @@ Next review in 1 day (). 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 │ diff --git a/tests/golden/normalise.awk b/tests/golden/normalise.awk index 5a4f042..eae800c 100644 --- a/tests/golden/normalise.awk +++ b/tests/golden/normalise.awk @@ -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(/(·|░|▒|▓|█| )$/, "", line) + if (line == " Reviews/day (local time):") heatmap = 0 line = collapse_graphics(line) line = collapse_cursor(line) gsub(/\r/, "", line) diff --git a/tests/tests.cpp b/tests/tests.cpp index e3ab857..4853a97 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -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)); @@ -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() { @@ -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() { @@ -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();