this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: review op log (cards.review-log.jsonl) + history chart on stats page

Appends one JSONL entry per grade in both GUI and CLI review paths.
Stats page gains a 30-day history bar chart and total-reviews count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+144 -5
+15 -1
crates/tala-cli/src/main.rs
··· 3 3 4 4 use clap::{Parser, Subcommand}; 5 5 use tala_format::{CardEntry, CardKind, parse_cards}; 6 - use tala_srs::{CardSchedule, Grade, Schedule, Sidecar, is_due, next_schedule, today_str}; 6 + use tala_srs::{ 7 + CardSchedule, Grade, ReviewLogEntry, Schedule, Sidecar, append_review_log, is_due, 8 + next_schedule, now_unix_secs, today_str, 9 + }; 7 10 8 11 #[derive(Parser)] 9 12 #[command(name = "tala", about = "Tala flashcard tools")] ··· 258 261 item.sub_id.clone(), 259 262 grade, 260 263 )); 264 + append_review_log( 265 + &files[item.file_idx].typ_path, 266 + &ReviewLogEntry { 267 + ts: now_unix_secs(), 268 + date: today_str(), 269 + card_id: item.card_id.clone(), 270 + item: item.sub_id.clone(), 271 + grade: grade as u8, 272 + }, 273 + ) 274 + .ok(); 261 275 262 276 // Write the new schedule into the in-memory sidecar. 263 277 let df = &mut files[item.file_idx];
+56
crates/tala-srs/src/lib.rs
··· 3 3 4 4 use serde::{Deserialize, Serialize}; 5 5 6 + // ── Review log ──────────────────────────────────────────────────────────────── 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct ReviewLogEntry { 10 + /// Unix timestamp (seconds UTC) of the review. 11 + pub ts: u64, 12 + /// Local date at review time (YYYY-MM-DD). Derived from `ts` at write time. 13 + pub date: String, 14 + pub card_id: String, 15 + /// Sub-item identifier: "forward", "reverse", "b0", "b1", "rect-r0", … 16 + pub item: String, 17 + /// FSRS grade 1–4. 18 + pub grade: u8, 19 + } 20 + 21 + /// Derive the review log path from a `.typ` file path. 22 + pub fn review_log_path(typ_path: &Path) -> PathBuf { 23 + typ_path.with_extension("review-log.jsonl") 24 + } 25 + 26 + /// Append one entry to the review log (creates the file if absent). 27 + pub fn append_review_log(typ_path: &Path, entry: &ReviewLogEntry) -> Result<(), Error> { 28 + use std::io::Write as _; 29 + let path = review_log_path(typ_path); 30 + let mut file = std::fs::OpenOptions::new() 31 + .create(true) 32 + .append(true) 33 + .open(&path)?; 34 + let line = serde_json::to_string(entry).map_err(Error::Json)?; 35 + writeln!(file, "{line}")?; 36 + Ok(()) 37 + } 38 + 39 + /// Load all entries from the review log. Unknown fields are ignored (forward-compat). 40 + pub fn load_review_log(typ_path: &Path) -> Result<Vec<ReviewLogEntry>, Error> { 41 + let path = review_log_path(typ_path); 42 + if !path.exists() { 43 + return Ok(Vec::new()); 44 + } 45 + let text = std::fs::read_to_string(&path)?; 46 + Ok(text 47 + .lines() 48 + .filter(|l| !l.trim().is_empty()) 49 + .filter_map(|l| serde_json::from_str(l).ok()) 50 + .collect()) 51 + } 52 + 53 + /// Current Unix timestamp in seconds (UTC). 54 + pub fn now_unix_secs() -> u64 { 55 + use std::time::{SystemTime, UNIX_EPOCH}; 56 + SystemTime::now() 57 + .duration_since(UNIX_EPOCH) 58 + .unwrap_or_default() 59 + .as_secs() 60 + } 61 + 6 62 // ── Schedule ────────────────────────────────────────────────────────────────── 7 63 8 64 /// FSRS scheduling state for a single reviewable item.
+1
crates/tala/assets/main.css
··· 344 344 } 345 345 346 346 .bar-today { background: #7aa2f7; } 347 + .bar-history { background: #3d5a80; } 347 348 348 349 .forecast-label { 349 350 font-size: 9px;
+17 -2
crates/tala/src/review.rs
··· 2 2 use dioxus::prelude::*; 3 3 use image::RgbaImage; 4 4 use tala_format::{CardKind, Direction}; 5 - use tala_srs::{CardSchedule, Grade, Schedule, Sidecar, is_due, next_schedule, today_str}; 5 + use tala_srs::{ 6 + CardSchedule, Grade, ReviewLogEntry, Schedule, Sidecar, append_review_log, is_due, 7 + next_schedule, now_unix_secs, today_str, 8 + }; 6 9 7 10 use crate::util::{TagMode, card_dir, cards_path, extract_img_names}; 8 11 ··· 52 55 }; 53 56 let days_elapsed = review_days_elapsed(&current); 54 57 let new_sched = next_schedule(current.as_ref(), days_elapsed, grade); 58 + let typ_path = cards_path(); 55 59 { 56 60 let mut sess = session.write(); 57 61 apply_review_schedule(&mut sess.sidecar, &card_id, &sub_id, new_sched, &kind); 58 - sess.sidecar.save_for(&cards_path()).ok(); 62 + sess.sidecar.save_for(&typ_path).ok(); 59 63 } 64 + append_review_log( 65 + &typ_path, 66 + &ReviewLogEntry { 67 + ts: now_unix_secs(), 68 + date: today_str(), 69 + card_id: card_id.clone(), 70 + item: sub_id.clone(), 71 + grade: grade as u8, 72 + }, 73 + ) 74 + .ok(); 60 75 *graded_count.write() += 1; 61 76 let next = cur_idx + 1; 62 77 if next >= session.read().queue.len() {
+55 -2
crates/tala/src/stats.rs
··· 1 1 use dioxus::prelude::*; 2 2 use tala_format::{CardKind, Direction, parse_cards}; 3 - use tala_srs::{CardSchedule, Sidecar, date_offset, today_str}; 3 + use tala_srs::{CardSchedule, Sidecar, date_offset, load_review_log, today_str}; 4 4 5 5 use crate::util::cards_path; 6 6 ··· 10 10 due_today: usize, 11 11 /// Item counts for today through today+13 (index 0 = today, already overdue included). 12 12 forecast: [usize; 14], 13 + /// Review counts per day for the past 30 days, index 0 = today, index 29 = 29 days ago. 14 + history: [usize; 30], 15 + total_reviews: usize, 13 16 } 14 17 15 18 fn compute_stats() -> DeckStats { ··· 92 95 } 93 96 } 94 97 95 - DeckStats { total_items, new_items, due_today, forecast } 98 + // ── History ────────────────────────────────────────────────────────────── 99 + let history_dates: [String; 30] = std::array::from_fn(|i| date_offset(-(i as i64))); 100 + let mut history = [0usize; 30]; 101 + let mut total_reviews = 0usize; 102 + if let Ok(log) = load_review_log(&typ_path) { 103 + for entry in &log { 104 + total_reviews += 1; 105 + if let Some(i) = history_dates.iter().position(|d| d == &entry.date) { 106 + history[i] += 1; 107 + } 108 + } 109 + } 110 + 111 + DeckStats { total_items, new_items, due_today, forecast, history, total_reviews } 96 112 } 97 113 98 114 #[component] ··· 101 117 102 118 let max_bar = *stats.forecast.iter().max().unwrap_or(&0); 103 119 let max_bar = max_bar.max(stats.due_today).max(1); 120 + 121 + let max_hist = *stats.history.iter().max().unwrap_or(&0).max(&1); 104 122 105 123 let forecast_labels: [String; 14] = std::array::from_fn(|i| { 106 124 if i == 0 { ··· 128 146 span { class: "stat-value stat-due", "{stats.due_today}" } 129 147 span { class: "stat-label", "due today" } 130 148 } 149 + div { class: "stat-box", 150 + span { class: "stat-value", "{stats.total_reviews}" } 151 + span { class: "stat-label", "total reviews" } 152 + } 153 + } 154 + h3 { "History (past 30 days)" } 155 + div { class: "forecast-chart", 156 + // index 29 = oldest, index 0 = today; render left-to-right oldest first 157 + {(0usize..30).rev().map(|i| { 158 + let count = stats.history[i]; 159 + let pct = count * 100 / max_hist; 160 + let label = if i == 0 { 161 + "today".to_string() 162 + } else { 163 + let d = date_offset(-(i as i64)); 164 + d[5..].replace('-', "/") 165 + }; 166 + rsx! { 167 + div { key: "h{i}", class: "forecast-col", 168 + span { class: "forecast-count", 169 + if count > 0 { "{count}" } 170 + } 171 + div { class: "forecast-bar-area", 172 + div { 173 + class: if i == 0 { "forecast-bar bar-today" } else { "forecast-bar bar-history" }, 174 + style: "height: {pct}%", 175 + } 176 + } 177 + span { class: "forecast-label", 178 + // only show label for every 7th bar to avoid clutter 179 + if i % 7 == 0 { "{label}" } 180 + } 181 + } 182 + } 183 + })} 131 184 } 132 185 h3 { "Upcoming" } 133 186 div { class: "forecast-chart",