chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "jcode-productivity-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rayon = "1"
|
||||
jcode-storage = { path = "../jcode-storage" }
|
||||
|
||||
# Dashboard rendering (SVG -> PNG)
|
||||
resvg = "0.46"
|
||||
usvg = "0.46"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,15 @@
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let t = std::time::Instant::now();
|
||||
let out = jcode_productivity_core::generate()?;
|
||||
eprintln!("--- generate() took {:.2}s ---", t.elapsed().as_secs_f64());
|
||||
eprintln!("PNG: {} ({} bytes)", out.png_path.display(), out.png.len());
|
||||
eprintln!(
|
||||
"scanned={} parse_errors={} cache_hits={} scan_secs={:.2}",
|
||||
out.report.scanned_files,
|
||||
out.report.parse_errors,
|
||||
out.report.cache_hits,
|
||||
out.report.scan_secs
|
||||
);
|
||||
println!("{}", out.markdown);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Fold per-session summaries into the global [`ProductivityReport`], including
|
||||
//! the shareable "flavor" fields (archetype, badges, power score).
|
||||
|
||||
use crate::model::{ProductivityReport, SessionSummary, Tally};
|
||||
use crate::scan::ScanResult;
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
fn top_n(map: HashMap<String, u64>, n: usize) -> Vec<Tally> {
|
||||
let mut v: Vec<Tally> = map
|
||||
.into_iter()
|
||||
.map(|(name, count)| Tally { name, count })
|
||||
.collect();
|
||||
v.sort_by(|a, b| b.count.cmp(&a.count).then(a.name.cmp(&b.name)));
|
||||
v.truncate(n);
|
||||
v
|
||||
}
|
||||
|
||||
/// Build the full report from a scan result.
|
||||
pub fn build_report(scan: ScanResult) -> ProductivityReport {
|
||||
let summaries = &scan.summaries;
|
||||
|
||||
let mut r = ProductivityReport {
|
||||
generated_at: Local::now().format("%Y-%m-%d %H:%M").to_string(),
|
||||
total_sessions: summaries.len() as u64,
|
||||
scanned_files: scan.scanned_files,
|
||||
parse_errors: scan.parse_errors,
|
||||
scan_secs: scan.scan_secs,
|
||||
cache_hits: scan.cache_hits,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut projects: HashMap<String, u64> = HashMap::new();
|
||||
let mut tools: HashMap<String, u64> = HashMap::new();
|
||||
let mut models: HashMap<String, u64> = HashMap::new();
|
||||
let mut providers: HashMap<String, u64> = HashMap::new();
|
||||
let mut all_dates: BTreeSet<String> = BTreeSet::new();
|
||||
let mut date_counts: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
let mut total_session_msgs: u64 = 0;
|
||||
|
||||
for s in summaries {
|
||||
let msgs = (s.user_msgs + s.assistant_msgs) as u64;
|
||||
r.total_messages += msgs;
|
||||
total_session_msgs += msgs;
|
||||
r.user_prompts += s.user_msgs as u64;
|
||||
r.assistant_messages += s.assistant_msgs as u64;
|
||||
r.total_tool_calls += s.total_tool_calls();
|
||||
r.total_images += s.images as u64;
|
||||
|
||||
r.input_tokens += s.input_tokens;
|
||||
r.output_tokens += s.output_tokens;
|
||||
r.cache_read_tokens += s.cache_read_tokens;
|
||||
r.cache_creation_tokens += s.cache_creation_tokens;
|
||||
|
||||
r.user_chars += s.user_chars;
|
||||
r.assistant_chars += s.assistant_chars;
|
||||
|
||||
if msgs > r.longest_session_msgs {
|
||||
r.longest_session_msgs = msgs;
|
||||
}
|
||||
|
||||
if let Some(p) = &s.project
|
||||
&& !p.is_empty()
|
||||
{
|
||||
*projects.entry(p.clone()).or_insert(0) += 1;
|
||||
}
|
||||
if let Some(m) = &s.model
|
||||
&& !m.is_empty()
|
||||
&& m != "<synthetic>"
|
||||
{
|
||||
*models.entry(m.clone()).or_insert(0) += msgs.max(1);
|
||||
}
|
||||
if let Some(pk) = &s.provider_key
|
||||
&& !pk.is_empty()
|
||||
{
|
||||
*providers.entry(pk.clone()).or_insert(0) += msgs.max(1);
|
||||
}
|
||||
|
||||
for (name, count) in &s.tools {
|
||||
*tools.entry(name.clone()).or_insert(0) += *count as u64;
|
||||
}
|
||||
|
||||
for h in 0..24 {
|
||||
r.hour_hist[h] += s.hour_hist[h];
|
||||
}
|
||||
for d in 0..7 {
|
||||
r.weekday_hist[d] += s.weekday_hist[d];
|
||||
}
|
||||
|
||||
for date in &s.active_dates {
|
||||
all_dates.insert(date.clone());
|
||||
*date_counts.entry(date.clone()).or_insert(0) += msgs.max(1);
|
||||
}
|
||||
}
|
||||
|
||||
r.user_words = (r.user_chars as f64 / 5.0).round() as u64;
|
||||
r.distinct_projects = projects.len() as u64;
|
||||
r.avg_session_msgs = if r.total_sessions > 0 {
|
||||
total_session_msgs as f64 / r.total_sessions as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Tool-derived activity buckets.
|
||||
let tool = |name: &str| tools.get(name).copied().unwrap_or(0);
|
||||
r.code_edits = tool("edit") + tool("write") + tool("multiedit") + tool("apply_patch");
|
||||
r.commands_run = tool("bash");
|
||||
r.searches = tool("grep") + tool("agentgrep") + tool("glob");
|
||||
r.web_actions = tool("browser") + tool("websearch") + tool("webfetch");
|
||||
|
||||
r.top_projects = top_n(projects, 8);
|
||||
r.top_tools = top_n(tools, 10);
|
||||
r.top_models = top_n(models, 6);
|
||||
r.top_providers = top_n(providers, 6);
|
||||
|
||||
// Time analysis -------------------------------------------------------
|
||||
r.active_days = all_dates.len() as u64;
|
||||
r.first_day = all_dates.iter().next().cloned();
|
||||
r.last_day = all_dates.iter().next_back().cloned();
|
||||
if let (Some(first), Some(last)) = (&r.first_day, &r.last_day)
|
||||
&& let (Ok(a), Ok(b)) = (
|
||||
NaiveDate::parse_from_str(first, "%Y-%m-%d"),
|
||||
NaiveDate::parse_from_str(last, "%Y-%m-%d"),
|
||||
)
|
||||
{
|
||||
r.span_days = (b - a).num_days().max(0) as u64 + 1;
|
||||
}
|
||||
let (cur, longest) = streaks(&all_dates);
|
||||
r.current_streak = cur;
|
||||
r.longest_streak = longest;
|
||||
if let Some((day, count)) = date_counts
|
||||
.iter()
|
||||
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
|
||||
{
|
||||
r.busiest_day = Some(Tally {
|
||||
name: day.clone(),
|
||||
count: *count,
|
||||
});
|
||||
}
|
||||
r.peak_hour = r
|
||||
.hour_hist
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, c)| **c)
|
||||
.map(|(h, _)| h as u8)
|
||||
.unwrap_or(0);
|
||||
r.chronotype = chronotype(r.peak_hour).to_string();
|
||||
|
||||
// Flavor --------------------------------------------------------------
|
||||
r.power_score = power_score(&r);
|
||||
let (archetype, blurb) = archetype(&r);
|
||||
r.archetype = archetype;
|
||||
r.archetype_blurb = blurb;
|
||||
r.badges = badges(&r);
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
/// Compute (current_streak, longest_streak) of consecutive active days ending at
|
||||
/// today (or yesterday). Current streak counts if the most recent active day is
|
||||
/// today or yesterday.
|
||||
fn streaks(dates: &BTreeSet<String>) -> (u64, u64) {
|
||||
let parsed: Vec<NaiveDate> = dates
|
||||
.iter()
|
||||
.filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
|
||||
.collect();
|
||||
if parsed.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
// Longest run of consecutive days.
|
||||
let mut longest = 1u64;
|
||||
let mut run = 1u64;
|
||||
for w in parsed.windows(2) {
|
||||
if (w[1] - w[0]).num_days() == 1 {
|
||||
run += 1;
|
||||
longest = longest.max(run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk backwards from the last active day, but only counts
|
||||
// as "current" if it reaches today or yesterday.
|
||||
let today = Local::now().date_naive();
|
||||
// `parsed` is guaranteed non-empty by the early return above.
|
||||
let Some(&last) = parsed.last() else {
|
||||
return (0, longest);
|
||||
};
|
||||
let mut current = 0u64;
|
||||
if (today - last).num_days() <= 1 {
|
||||
current = 1;
|
||||
let mut idx = parsed.len() - 1;
|
||||
while idx > 0 {
|
||||
if (parsed[idx] - parsed[idx - 1]).num_days() == 1 {
|
||||
current += 1;
|
||||
idx -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(current, longest)
|
||||
}
|
||||
|
||||
fn chronotype(peak_hour: u8) -> &'static str {
|
||||
match peak_hour {
|
||||
5..=8 => "Early Bird",
|
||||
9..=11 => "Morning Person",
|
||||
12..=16 => "Afternoon Operator",
|
||||
17..=20 => "Evening Builder",
|
||||
21..=23 => "Night Owl",
|
||||
_ => "Nocturnal Hacker",
|
||||
}
|
||||
}
|
||||
|
||||
/// A single headline number that's fun to compare. Weighted toward output and
|
||||
/// breadth rather than raw token spend.
|
||||
fn power_score(r: &ProductivityReport) -> u64 {
|
||||
let from_tools = r.total_tool_calls as f64 * 1.0;
|
||||
let from_edits = r.code_edits as f64 * 4.0;
|
||||
let from_prompts = r.user_prompts as f64 * 2.0;
|
||||
let from_days = r.active_days as f64 * 25.0;
|
||||
let from_streak = r.longest_streak as f64 * 15.0;
|
||||
let from_projects = r.distinct_projects as f64 * 10.0;
|
||||
let from_tokens = (r.output_tokens as f64 / 1000.0) * 1.0;
|
||||
(from_tools + from_edits + from_prompts + from_days + from_streak + from_projects + from_tokens)
|
||||
.round() as u64
|
||||
}
|
||||
|
||||
/// Pick a primary archetype from the dominant activity mix.
|
||||
fn archetype(r: &ProductivityReport) -> (String, String) {
|
||||
let edits = r.code_edits as f64;
|
||||
let cmds = r.commands_run as f64;
|
||||
let search = r.searches as f64;
|
||||
let web = r.web_actions as f64;
|
||||
let prompts = r.user_prompts.max(1) as f64;
|
||||
|
||||
// Ratios relative to total prompts give a stable signal across volume.
|
||||
let edit_ratio = edits / prompts;
|
||||
let cmd_ratio = cmds / prompts;
|
||||
let search_ratio = search / prompts;
|
||||
let web_ratio = web / prompts;
|
||||
|
||||
let mut candidates: Vec<(&str, f64, &str)> = vec![
|
||||
(
|
||||
"The Shipper",
|
||||
edit_ratio,
|
||||
"You turn conversations into committed code. Edits per prompt off the charts.",
|
||||
),
|
||||
(
|
||||
"The Operator",
|
||||
cmd_ratio,
|
||||
"Terminal-first. You drive builds, tests, and tooling like a control panel.",
|
||||
),
|
||||
(
|
||||
"The Explorer",
|
||||
search_ratio,
|
||||
"You read before you write. Codebase navigation is your superpower.",
|
||||
),
|
||||
(
|
||||
"The Researcher",
|
||||
web_ratio * 3.0,
|
||||
"You pull the wider world into your work, browsing and searching the web.",
|
||||
),
|
||||
];
|
||||
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let top = candidates[0];
|
||||
if top.1 <= 0.0001 {
|
||||
return (
|
||||
"The Conversationalist".to_string(),
|
||||
"Mostly thinking out loud with your agent. The ideas come first.".to_string(),
|
||||
);
|
||||
}
|
||||
(top.0.to_string(), top.2.to_string())
|
||||
}
|
||||
|
||||
fn badges(r: &ProductivityReport) -> Vec<String> {
|
||||
let mut b = Vec::new();
|
||||
if r.longest_streak >= 7 {
|
||||
b.push(format!("🔥 {}-day streak", r.longest_streak));
|
||||
}
|
||||
if r.distinct_projects >= 10 {
|
||||
b.push(format!("🗂️ {} projects", r.distinct_projects));
|
||||
}
|
||||
if r.output_tokens >= 10_000_000 {
|
||||
b.push("📈 10M+ tokens generated".to_string());
|
||||
}
|
||||
if r.code_edits >= 1000 {
|
||||
b.push(format!("🛠️ {} code edits", r.code_edits));
|
||||
}
|
||||
if matches!(r.peak_hour, 0..=4 | 22..=23) {
|
||||
b.push("🦉 Night owl".to_string());
|
||||
}
|
||||
if r.total_sessions >= 1000 {
|
||||
b.push(format!("💬 {} sessions", r.total_sessions));
|
||||
}
|
||||
// Weekend warrior: meaningful share of activity on Sat/Sun.
|
||||
let weekend: u32 = r.weekday_hist[5] + r.weekday_hist[6];
|
||||
let total: u32 = r.weekday_hist.iter().sum();
|
||||
if total > 0 && (weekend as f64 / total as f64) > 0.30 {
|
||||
b.push("🏕️ Weekend warrior".to_string());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn weekday_label(idx: usize) -> &'static str {
|
||||
match idx {
|
||||
0 => "Mon",
|
||||
1 => "Tue",
|
||||
2 => "Wed",
|
||||
3 => "Thu",
|
||||
4 => "Fri",
|
||||
5 => "Sat",
|
||||
_ => "Sun",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for callers that just want today's full report.
|
||||
pub fn report_from_summaries(summaries: Vec<SessionSummary>) -> ProductivityReport {
|
||||
build_report(ScanResult {
|
||||
scanned_files: summaries.len() as u64,
|
||||
parse_errors: 0,
|
||||
cache_hits: 0,
|
||||
scan_secs: 0.0,
|
||||
summaries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Local weekday index (Mon=0) for today, used by some renderers.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn today_weekday() -> usize {
|
||||
Local::now().weekday().num_days_from_monday() as usize
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Build a shareable dashboard as an SVG and rasterize it to PNG.
|
||||
//!
|
||||
//! The SVG is assembled by hand (no templating dep) into a dark, card-based
|
||||
//! "wrapped"-style poster, then rendered to a PNG via resvg/usvg/tiny-skia using
|
||||
//! the system font database.
|
||||
|
||||
use crate::markdown::human;
|
||||
use crate::model::ProductivityReport;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
const W: f32 = 1200.0;
|
||||
const H: f32 = 1440.0;
|
||||
|
||||
// Palette (dark, high-contrast, screenshot-friendly).
|
||||
const BG: &str = "#0b0f17";
|
||||
const CARD: &str = "#151b27";
|
||||
const CARD2: &str = "#1b2230";
|
||||
const ACCENT: &str = "#7c9cff";
|
||||
const ACCENT2: &str = "#5ce1a6";
|
||||
const ACCENT3: &str = "#ffb86b";
|
||||
const TEXT: &str = "#e8edf6";
|
||||
const MUTED: &str = "#8a94a7";
|
||||
const TRACK: &str = "#222a3a";
|
||||
|
||||
static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
Arc::new(db)
|
||||
});
|
||||
|
||||
/// Escape text for inclusion in SVG element bodies/attributes.
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Truncate to a max display length with an ellipsis.
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
if chars.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut out: String = chars[..max.saturating_sub(1)].iter().collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop emoji / pictographic codepoints the system sans font can't render, so
|
||||
/// the PNG never shows tofu boxes. Keeps ASCII + common latin/punctuation.
|
||||
fn ascii_only(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let keep = c.is_ascii() || matches!(c, '…' | '·' | '–' | '—' | '’' | '“' | '”');
|
||||
if keep {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
// Collapse runs of whitespace left behind by removed emoji.
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
struct Svg {
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Svg {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
body: String::with_capacity(16 * 1024),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32, fill: &str) {
|
||||
self.body.push_str(&format!(
|
||||
"<rect x='{x:.1}' y='{y:.1}' width='{w:.1}' height='{h:.1}' rx='{r:.1}' ry='{r:.1}' fill='{fill}'/>"
|
||||
));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rect_op(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32, fill: &str, opacity: f32) {
|
||||
self.body.push_str(&format!(
|
||||
"<rect x='{x:.1}' y='{y:.1}' width='{w:.1}' height='{h:.1}' rx='{r:.1}' ry='{r:.1}' fill='{fill}' fill-opacity='{opacity:.2}'/>"
|
||||
));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, fill: &str, weight: u32, anchor: &str) {
|
||||
self.body.push_str(&format!(
|
||||
"<text x='{x:.1}' y='{y:.1}' font-family='Inter, \"Liberation Sans\", \"DejaVu Sans\", sans-serif' font-size='{size:.1}' font-weight='{weight}' fill='{fill}' text-anchor='{anchor}'>{}</text>",
|
||||
esc(s)
|
||||
));
|
||||
}
|
||||
|
||||
/// Draw a small lightning bolt glyph whose bounding box is roughly
|
||||
/// `size` tall, with its top-left at (x, y).
|
||||
fn bolt(&mut self, x: f32, y: f32, size: f32, fill: &str) {
|
||||
let s = size;
|
||||
// Simple zig-zag bolt polygon.
|
||||
let pts = [
|
||||
(0.55, 0.0),
|
||||
(0.10, 0.58),
|
||||
(0.45, 0.58),
|
||||
(0.30, 1.0),
|
||||
(0.90, 0.40),
|
||||
(0.52, 0.40),
|
||||
(0.80, 0.0),
|
||||
];
|
||||
let path: String = pts
|
||||
.iter()
|
||||
.map(|(px, py)| format!("{:.1},{:.1}", x + px * s, y + py * s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
self.body
|
||||
.push_str(&format!("<polygon points='{path}' fill='{fill}'/>"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the dashboard SVG string for a report.
|
||||
pub fn render_svg(r: &ProductivityReport) -> String {
|
||||
let mut svg = Svg::new();
|
||||
|
||||
// Background with a subtle top gradient bar.
|
||||
svg.rect(0.0, 0.0, W, H, 0.0, BG);
|
||||
svg.body.push_str(&format!(
|
||||
"<defs><linearGradient id='hd' x1='0' y1='0' x2='1' y2='0'>\
|
||||
<stop offset='0' stop-color='{ACCENT}'/>\
|
||||
<stop offset='0.5' stop-color='{ACCENT2}'/>\
|
||||
<stop offset='1' stop-color='{ACCENT3}'/></linearGradient></defs>"
|
||||
));
|
||||
svg.rect(0.0, 0.0, W, 10.0, 0.0, "url(#hd)");
|
||||
|
||||
let pad = 56.0;
|
||||
|
||||
// ---- Header ----
|
||||
svg.text(pad, 92.0, "jcode", 30.0, ACCENT, 800, "start");
|
||||
svg.text(pad, 92.0 + 0.0, "", 1.0, TEXT, 400, "start");
|
||||
svg.text(
|
||||
W - pad,
|
||||
70.0,
|
||||
"PRODUCTIVITY REPORT",
|
||||
18.0,
|
||||
MUTED,
|
||||
700,
|
||||
"end",
|
||||
);
|
||||
svg.text(W - pad, 96.0, &r.generated_at, 16.0, MUTED, 400, "end");
|
||||
|
||||
svg.text(pad, 158.0, &r.archetype, 52.0, TEXT, 800, "start");
|
||||
svg.text(
|
||||
pad,
|
||||
196.0,
|
||||
&clip(&r.archetype_blurb, 78),
|
||||
20.0,
|
||||
MUTED,
|
||||
400,
|
||||
"start",
|
||||
);
|
||||
|
||||
// Power score pill (right aligned).
|
||||
let pill_w = 320.0;
|
||||
let pill_x = W - pad - pill_w;
|
||||
svg.rect(pill_x, 128.0, pill_w, 78.0, 18.0, CARD2);
|
||||
svg.text(
|
||||
pill_x + 24.0,
|
||||
160.0,
|
||||
"POWER SCORE",
|
||||
15.0,
|
||||
MUTED,
|
||||
700,
|
||||
"start",
|
||||
);
|
||||
let score = human(r.power_score);
|
||||
svg.text(
|
||||
pill_x + pill_w - 24.0,
|
||||
186.0,
|
||||
&score,
|
||||
38.0,
|
||||
ACCENT2,
|
||||
800,
|
||||
"end",
|
||||
);
|
||||
// Lightning bolt to the left of the score number.
|
||||
let score_w = score.chars().count() as f32 * 22.0;
|
||||
svg.bolt(
|
||||
pill_x + pill_w - 24.0 - score_w - 34.0,
|
||||
154.0,
|
||||
34.0,
|
||||
ACCENT3,
|
||||
);
|
||||
|
||||
// ---- Stat cards grid ----
|
||||
let grid_top = 240.0;
|
||||
let cols = 4usize;
|
||||
let gap = 20.0;
|
||||
let card_w = (W - 2.0 * pad - gap * (cols as f32 - 1.0)) / cols as f32;
|
||||
let card_h = 118.0;
|
||||
|
||||
let cards: Vec<(String, String, &str)> = vec![
|
||||
(human(r.total_sessions), "Sessions".into(), ACCENT),
|
||||
(human(r.user_prompts), "Prompts".into(), ACCENT),
|
||||
(human(r.total_tool_calls), "Tool calls".into(), ACCENT2),
|
||||
(human(r.code_edits), "Code edits".into(), ACCENT2),
|
||||
(human(r.output_tokens), "Tokens out".into(), ACCENT3),
|
||||
(human(r.input_tokens), "Tokens in".into(), ACCENT3),
|
||||
(human(r.cache_read_tokens), "Cache reads".into(), ACCENT3),
|
||||
(human(r.distinct_projects), "Projects".into(), ACCENT),
|
||||
(r.active_days.to_string(), "Active days".into(), ACCENT2),
|
||||
(
|
||||
format!("{}", r.longest_streak),
|
||||
"Best streak (days)".into(),
|
||||
ACCENT3,
|
||||
),
|
||||
(human(r.commands_run), "Commands".into(), ACCENT),
|
||||
(human(r.searches), "Searches".into(), ACCENT2),
|
||||
];
|
||||
|
||||
for (i, (value, label, color)) in cards.iter().enumerate() {
|
||||
let col = i % cols;
|
||||
let row = i / cols;
|
||||
let x = pad + col as f32 * (card_w + gap);
|
||||
let y = grid_top + row as f32 * (card_h + gap);
|
||||
svg.rect(x, y, card_w, card_h, 16.0, CARD);
|
||||
svg.text(x + 22.0, y + 56.0, value, 38.0, color, 800, "start");
|
||||
svg.text(x + 22.0, y + 90.0, label, 17.0, MUTED, 500, "start");
|
||||
}
|
||||
|
||||
let after_grid = grid_top + 3.0 * (card_h + gap) + 16.0;
|
||||
|
||||
// ---- Two-column section: hour rhythm + weekday ----
|
||||
let sec_h = 250.0;
|
||||
let half_w = (W - 2.0 * pad - gap) / 2.0;
|
||||
|
||||
// Hour-of-day chart.
|
||||
let hx = pad;
|
||||
let hy = after_grid;
|
||||
svg.rect(hx, hy, half_w, sec_h, 16.0, CARD);
|
||||
svg.text(
|
||||
hx + 22.0,
|
||||
hy + 38.0,
|
||||
"Daily rhythm",
|
||||
22.0,
|
||||
TEXT,
|
||||
700,
|
||||
"start",
|
||||
);
|
||||
svg.text(
|
||||
hx + half_w - 22.0,
|
||||
hy + 38.0,
|
||||
&format!("peak {} ", crate::markdown::hour_label_pub(r.peak_hour)),
|
||||
16.0,
|
||||
MUTED,
|
||||
500,
|
||||
"end",
|
||||
);
|
||||
draw_hour_bars(
|
||||
&mut svg,
|
||||
hx + 22.0,
|
||||
hy + 62.0,
|
||||
half_w - 44.0,
|
||||
150.0,
|
||||
&r.hour_hist,
|
||||
);
|
||||
|
||||
// Weekday chart.
|
||||
let wx = pad + half_w + gap;
|
||||
let wy = after_grid;
|
||||
svg.rect(wx, wy, half_w, sec_h, 16.0, CARD);
|
||||
svg.text(wx + 22.0, wy + 38.0, "By weekday", 22.0, TEXT, 700, "start");
|
||||
draw_weekday_bars(
|
||||
&mut svg,
|
||||
wx + 22.0,
|
||||
wy + 62.0,
|
||||
half_w - 44.0,
|
||||
150.0,
|
||||
&r.weekday_hist,
|
||||
);
|
||||
|
||||
let after_charts = after_grid + sec_h + gap;
|
||||
|
||||
// ---- Two-column: top tools + top projects ----
|
||||
let list_h = 330.0;
|
||||
let tx = pad;
|
||||
let ty = after_charts;
|
||||
svg.rect(tx, ty, half_w, list_h, 16.0, CARD);
|
||||
svg.text(
|
||||
tx + 22.0,
|
||||
ty + 38.0,
|
||||
"Most-used tools",
|
||||
22.0,
|
||||
TEXT,
|
||||
700,
|
||||
"start",
|
||||
);
|
||||
draw_tool_list(&mut svg, tx + 22.0, ty + 64.0, half_w - 44.0, &r.top_tools);
|
||||
|
||||
let px = pad + half_w + gap;
|
||||
let py = after_charts;
|
||||
svg.rect(px, py, half_w, list_h, 16.0, CARD);
|
||||
svg.text(
|
||||
px + 22.0,
|
||||
py + 38.0,
|
||||
"Top projects",
|
||||
22.0,
|
||||
TEXT,
|
||||
700,
|
||||
"start",
|
||||
);
|
||||
draw_project_list(
|
||||
&mut svg,
|
||||
px + 22.0,
|
||||
py + 64.0,
|
||||
half_w - 44.0,
|
||||
&r.top_projects,
|
||||
);
|
||||
|
||||
let after_lists = after_charts + list_h + gap;
|
||||
|
||||
// ---- Badges strip ----
|
||||
if !r.badges.is_empty() {
|
||||
let by = after_lists;
|
||||
let bh = 70.0;
|
||||
svg.rect(pad, by, W - 2.0 * pad, bh, 16.0, CARD2);
|
||||
let mut bx = pad + 22.0;
|
||||
for badge in r.badges.iter() {
|
||||
let label = clip(&ascii_only(badge), 24);
|
||||
if label.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let chip_w = 30.0 + label.chars().count() as f32 * 10.5;
|
||||
if bx + chip_w > W - pad - 20.0 {
|
||||
break;
|
||||
}
|
||||
svg.rect_op(bx, by + 16.0, chip_w, 38.0, 19.0, ACCENT, 0.14);
|
||||
svg.text(bx + 16.0, by + 41.0, &label, 17.0, TEXT, 600, "start");
|
||||
bx += chip_w + 14.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Footer ----
|
||||
svg.text(
|
||||
pad,
|
||||
H - 30.0,
|
||||
&format!(
|
||||
"{} active days · {} day span · chronotype: {}",
|
||||
r.active_days, r.span_days, r.chronotype
|
||||
),
|
||||
16.0,
|
||||
MUTED,
|
||||
400,
|
||||
"start",
|
||||
);
|
||||
svg.text(
|
||||
W - pad,
|
||||
H - 30.0,
|
||||
"generated with jcode · /productivity",
|
||||
16.0,
|
||||
ACCENT,
|
||||
600,
|
||||
"end",
|
||||
);
|
||||
|
||||
format!(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='{W}' height='{H}' viewBox='0 0 {W} {H}'>{}</svg>",
|
||||
svg.body
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_hour_bars(svg: &mut Svg, x: f32, y: f32, w: f32, h: f32, hist: &[u32; 24]) {
|
||||
let max = hist.iter().copied().max().unwrap_or(1).max(1) as f32;
|
||||
let n = 24usize;
|
||||
let gap = 4.0;
|
||||
let bar_w = (w - gap * (n as f32 - 1.0)) / n as f32;
|
||||
for (i, &v) in hist.iter().enumerate() {
|
||||
let bh = (v as f32 / max) * h;
|
||||
let bx = x + i as f32 * (bar_w + gap);
|
||||
// track
|
||||
svg.rect(bx, y, bar_w, h, 3.0, TRACK);
|
||||
if bh > 0.5 {
|
||||
svg.rect(bx, y + (h - bh), bar_w, bh, 3.0, ACCENT);
|
||||
}
|
||||
}
|
||||
// hour ticks
|
||||
for &hh in &[0usize, 6, 12, 18, 23] {
|
||||
let bx = x + hh as f32 * (bar_w + gap) + bar_w / 2.0;
|
||||
svg.text(
|
||||
bx,
|
||||
y + h + 22.0,
|
||||
&format!("{}h", hh),
|
||||
13.0,
|
||||
MUTED,
|
||||
400,
|
||||
"middle",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_weekday_bars(svg: &mut Svg, x: f32, y: f32, w: f32, h: f32, hist: &[u32; 7]) {
|
||||
let labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
let max = hist.iter().copied().max().unwrap_or(1).max(1) as f32;
|
||||
let n = 7usize;
|
||||
let gap = 14.0;
|
||||
let bar_w = (w - gap * (n as f32 - 1.0)) / n as f32;
|
||||
for i in 0..n {
|
||||
let v = hist[i] as f32;
|
||||
let bh = (v / max) * h;
|
||||
let bx = x + i as f32 * (bar_w + gap);
|
||||
svg.rect(bx, y, bar_w, h, 4.0, TRACK);
|
||||
if bh > 0.5 {
|
||||
let color = if i >= 5 { ACCENT3 } else { ACCENT2 };
|
||||
svg.rect(bx, y + (h - bh), bar_w, bh, 4.0, color);
|
||||
}
|
||||
svg.text(
|
||||
bx + bar_w / 2.0,
|
||||
y + h + 22.0,
|
||||
labels[i],
|
||||
13.0,
|
||||
MUTED,
|
||||
400,
|
||||
"middle",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tool_list(svg: &mut Svg, x: f32, y: f32, w: f32, tools: &[crate::model::Tally]) {
|
||||
let max = tools.first().map(|t| t.count).unwrap_or(1).max(1) as f32;
|
||||
let row_h = 34.0;
|
||||
let label_w = 110.0;
|
||||
let val_w = 70.0;
|
||||
let bar_x = x + label_w;
|
||||
let bar_w = w - label_w - val_w;
|
||||
for (i, t) in tools.iter().take(8).enumerate() {
|
||||
let ry = y + i as f32 * row_h;
|
||||
svg.text(x, ry + 6.0, &clip(&t.name, 12), 16.0, TEXT, 500, "start");
|
||||
svg.rect(bar_x, ry - 10.0, bar_w, 16.0, 8.0, TRACK);
|
||||
let fill = (t.count as f32 / max) * bar_w;
|
||||
if fill > 1.0 {
|
||||
svg.rect(bar_x, ry - 10.0, fill, 16.0, 8.0, ACCENT);
|
||||
}
|
||||
svg.text(x + w, ry + 6.0, &human(t.count), 15.0, MUTED, 600, "end");
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_project_list(svg: &mut Svg, x: f32, y: f32, w: f32, projects: &[crate::model::Tally]) {
|
||||
let row_h = 34.0;
|
||||
for (i, p) in projects.iter().take(8).enumerate() {
|
||||
let ry = y + i as f32 * row_h;
|
||||
svg.text(
|
||||
x,
|
||||
ry + 6.0,
|
||||
&format!("{}. {}", i + 1, clip(&p.name, 22)),
|
||||
16.0,
|
||||
TEXT,
|
||||
500,
|
||||
"start",
|
||||
);
|
||||
svg.text(
|
||||
x + w,
|
||||
ry + 6.0,
|
||||
&format!("{}", p.count),
|
||||
15.0,
|
||||
MUTED,
|
||||
600,
|
||||
"end",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterize the dashboard SVG to PNG bytes.
|
||||
pub fn render_png(r: &ProductivityReport) -> Result<Vec<u8>> {
|
||||
let svg = render_svg(r);
|
||||
let opt = usvg::Options {
|
||||
font_family: "Inter".to_string(),
|
||||
fontdb: FONT_DB.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_str(&svg, &opt).context("parse dashboard svg")?;
|
||||
let size = tree.size().to_int_size();
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height())
|
||||
.context("allocate dashboard pixmap")?;
|
||||
resvg::render(
|
||||
&tree,
|
||||
resvg::tiny_skia::Transform::default(),
|
||||
&mut pixmap.as_mut(),
|
||||
);
|
||||
pixmap.encode_png().context("encode dashboard png")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Productivity report generation for jcode.
|
||||
//!
|
||||
//! Scans local session transcripts (with an incremental cache), computes
|
||||
//! interesting + shareable usage statistics, and renders them as both Markdown
|
||||
//! and a PNG dashboard suitable for sharing.
|
||||
//!
|
||||
//! High-level entry point: [`generate`].
|
||||
|
||||
mod aggregate;
|
||||
mod dashboard;
|
||||
mod markdown;
|
||||
mod model;
|
||||
mod scan;
|
||||
|
||||
pub use model::{ProductivityReport, SessionSummary, Tally};
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Everything a caller needs to display and share the report.
|
||||
pub struct ProductivityOutput {
|
||||
pub report: ProductivityReport,
|
||||
/// Rendered Markdown for the chat transcript.
|
||||
pub markdown: String,
|
||||
/// PNG dashboard bytes (also written to `png_path`).
|
||||
pub png: Vec<u8>,
|
||||
/// Where the PNG was saved on disk.
|
||||
pub png_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Scan transcripts, compute the report, render markdown + PNG, and persist the
|
||||
/// PNG to `~/.jcode/generated-images/productivity-<timestamp>.png`.
|
||||
pub fn generate() -> Result<ProductivityOutput> {
|
||||
let report = compute_report()?;
|
||||
let markdown = markdown::render_markdown(&report);
|
||||
let png = dashboard::render_png(&report)?;
|
||||
let png_path = save_png(&png)?;
|
||||
Ok(ProductivityOutput {
|
||||
report,
|
||||
markdown,
|
||||
png,
|
||||
png_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan + aggregate only (no rendering). Useful for tests and JSON export.
|
||||
pub fn compute_report() -> Result<ProductivityReport> {
|
||||
let scan = scan::scan_all()?;
|
||||
Ok(aggregate::build_report(scan))
|
||||
}
|
||||
|
||||
/// Render markdown for a report.
|
||||
pub fn render_markdown(report: &ProductivityReport) -> String {
|
||||
markdown::render_markdown(report)
|
||||
}
|
||||
|
||||
/// Render the dashboard PNG bytes for a report.
|
||||
pub fn render_png(report: &ProductivityReport) -> Result<Vec<u8>> {
|
||||
dashboard::render_png(report)
|
||||
}
|
||||
|
||||
/// Render the dashboard SVG string for a report (useful for debugging/preview).
|
||||
pub fn render_svg(report: &ProductivityReport) -> String {
|
||||
dashboard::render_svg(report)
|
||||
}
|
||||
|
||||
fn save_png(png: &[u8]) -> Result<PathBuf> {
|
||||
let dir = jcode_storage::jcode_dir()?.join("generated-images");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let path = dir.join(format!("productivity-{ts}.png"));
|
||||
std::fs::write(&path, png)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Build a report directly from in-memory summaries. Exposed for tests.
|
||||
pub fn report_from_summaries(summaries: Vec<SessionSummary>) -> ProductivityReport {
|
||||
aggregate::report_from_summaries(summaries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Markdown rendering of a [`ProductivityReport`] for the chat transcript.
|
||||
|
||||
use crate::model::ProductivityReport;
|
||||
|
||||
/// Human-readable big-number formatting: 1234 -> "1.2K", 1_500_000 -> "1.5M".
|
||||
pub fn human(n: u64) -> String {
|
||||
if n >= 1_000_000_000 {
|
||||
format!("{:.1}B", n as f64 / 1_000_000_000.0)
|
||||
} else if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn hour_label(h: u8) -> String {
|
||||
let suffix = if h < 12 { "am" } else { "pm" };
|
||||
let display = match h % 12 {
|
||||
0 => 12,
|
||||
other => other,
|
||||
};
|
||||
format!("{}{}", display, suffix)
|
||||
}
|
||||
|
||||
/// Public alias of [`hour_label`] for use by the dashboard renderer.
|
||||
pub fn hour_label_pub(h: u8) -> String {
|
||||
hour_label(h)
|
||||
}
|
||||
|
||||
/// Render the full markdown report.
|
||||
pub fn render_markdown(r: &ProductivityReport) -> String {
|
||||
let mut o = String::new();
|
||||
|
||||
o.push_str("# 📊 Your jcode Productivity Report\n\n");
|
||||
o.push_str(&format!(
|
||||
"**{}** · _{}_\n\n",
|
||||
r.archetype, r.archetype_blurb
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"> ⚡ **Power Score: {}**\n\n",
|
||||
human(r.power_score)
|
||||
));
|
||||
|
||||
if !r.badges.is_empty() {
|
||||
o.push_str(&r.badges.join(" · "));
|
||||
o.push_str("\n\n");
|
||||
}
|
||||
|
||||
// Headline grid.
|
||||
o.push_str("## At a glance\n\n");
|
||||
o.push_str("| Metric | Value |\n|---|---|\n");
|
||||
o.push_str(&format!("| 💬 Sessions | {} |\n", human(r.total_sessions)));
|
||||
o.push_str(&format!(
|
||||
"| 🙋 Prompts sent | {} |\n",
|
||||
human(r.user_prompts)
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| 🛠️ Tool calls | {} |\n",
|
||||
human(r.total_tool_calls)
|
||||
));
|
||||
o.push_str(&format!("| ✍️ Code edits | {} |\n", human(r.code_edits)));
|
||||
o.push_str(&format!(
|
||||
"| 🧠 Tokens out / in | {} / {} |\n",
|
||||
human(r.output_tokens),
|
||||
human(r.input_tokens)
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| ♻️ Cache reads | {} |\n",
|
||||
human(r.cache_read_tokens)
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| 📅 Active days | {} (of {} day span) |\n",
|
||||
r.active_days, r.span_days
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| 🔥 Streak | {} now · {} best |\n",
|
||||
r.current_streak, r.longest_streak
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| 🗂️ Projects | {} |\n",
|
||||
human(r.distinct_projects)
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"| ⏰ Peak hour | {} ({}) |\n",
|
||||
hour_label(r.peak_hour),
|
||||
r.chronotype
|
||||
));
|
||||
o.push('\n');
|
||||
|
||||
// Human effort framing.
|
||||
o.push_str(&format!(
|
||||
"You've typed about **{} words** of prompts and your agent produced **{} characters** back.\n\n",
|
||||
human(r.user_words),
|
||||
human(r.assistant_chars)
|
||||
));
|
||||
|
||||
// Top projects.
|
||||
if !r.top_projects.is_empty() {
|
||||
o.push_str("## 🗂️ Top projects\n\n");
|
||||
for (i, t) in r.top_projects.iter().enumerate() {
|
||||
o.push_str(&format!(
|
||||
"{}. **{}** — {} sessions\n",
|
||||
i + 1,
|
||||
t.name,
|
||||
t.count
|
||||
));
|
||||
}
|
||||
o.push('\n');
|
||||
}
|
||||
|
||||
// Top tools with mini bars.
|
||||
if !r.top_tools.is_empty() {
|
||||
o.push_str("## 🧰 Most-used tools\n\n");
|
||||
let max = r.top_tools.first().map(|t| t.count).unwrap_or(1).max(1);
|
||||
for t in &r.top_tools {
|
||||
let bar = bar_for(t.count, max, 20);
|
||||
o.push_str(&format!("- `{:<10}` {} {}\n", t.name, bar, human(t.count)));
|
||||
}
|
||||
o.push('\n');
|
||||
}
|
||||
|
||||
// Models.
|
||||
if !r.top_models.is_empty() {
|
||||
o.push_str("## 🤖 Models\n\n");
|
||||
for t in &r.top_models {
|
||||
o.push_str(&format!("- {} ({})\n", t.name, human(t.count)));
|
||||
}
|
||||
o.push('\n');
|
||||
}
|
||||
|
||||
// Daily rhythm: 24h sparkline.
|
||||
o.push_str("## 🕒 Daily rhythm\n\n");
|
||||
o.push_str("```\n");
|
||||
o.push_str(&sparkline(&r.hour_hist));
|
||||
o.push_str("\n0h 6h 12h 18h 23h\n");
|
||||
o.push_str("```\n\n");
|
||||
|
||||
if let Some(busy) = &r.busiest_day {
|
||||
o.push_str(&format!(
|
||||
"Busiest day: **{}** ({} actions).\n\n",
|
||||
busy.name,
|
||||
human(busy.count)
|
||||
));
|
||||
}
|
||||
|
||||
o.push_str(&format!(
|
||||
"_Generated {} · scanned {} sessions in {:.1}s ({} cached). 🖼️ Dashboard image copied to your clipboard._\n",
|
||||
r.generated_at,
|
||||
human(r.scanned_files),
|
||||
r.scan_secs,
|
||||
human(r.cache_hits)
|
||||
));
|
||||
|
||||
o
|
||||
}
|
||||
|
||||
fn bar_for(count: u64, max: u64, width: usize) -> String {
|
||||
let filled = ((count as f64 / max as f64) * width as f64).round() as usize;
|
||||
let filled = filled.min(width);
|
||||
let mut s = String::new();
|
||||
for _ in 0..filled {
|
||||
s.push('█');
|
||||
}
|
||||
for _ in filled..width {
|
||||
s.push('░');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Render a unicode block sparkline over a 24-slot histogram.
|
||||
pub fn sparkline(hist: &[u32; 24]) -> String {
|
||||
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
let max = hist.iter().copied().max().unwrap_or(0).max(1);
|
||||
let mut s = String::new();
|
||||
for &v in hist.iter() {
|
||||
if v == 0 {
|
||||
s.push(' ');
|
||||
} else {
|
||||
let idx = (((v as f64 / max as f64) * (BLOCKS.len() - 1) as f64).round() as usize)
|
||||
.min(BLOCKS.len() - 1);
|
||||
s.push(BLOCKS[idx]);
|
||||
}
|
||||
s.push(' ');
|
||||
}
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Core data model for the productivity report.
|
||||
//!
|
||||
//! `SessionSummary` is the compact, cache-friendly per-session aggregate that we
|
||||
//! persist between runs so re-scanning ~100k transcripts stays fast. The global
|
||||
//! [`ProductivityReport`] is computed by folding all summaries together.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Compact per-session aggregate. Designed to be cheap to (de)serialize so we
|
||||
/// can cache one of these per transcript file and only re-parse changed files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionSummary {
|
||||
/// First and last activity timestamps (RFC3339, UTC), if known.
|
||||
pub first_ts: Option<String>,
|
||||
pub last_ts: Option<String>,
|
||||
/// Session created/updated timestamps from the file header.
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
|
||||
/// Project identity (basename of working_dir) and full path.
|
||||
pub project: Option<String>,
|
||||
pub working_dir: Option<String>,
|
||||
pub provider_key: Option<String>,
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Message counts.
|
||||
pub user_msgs: u32,
|
||||
pub assistant_msgs: u32,
|
||||
|
||||
/// Characters the human typed (sum of user text blocks). Rough "words" proxy.
|
||||
pub user_chars: u64,
|
||||
/// Characters the assistant produced in text blocks.
|
||||
pub assistant_chars: u64,
|
||||
|
||||
/// Tool invocation histogram. Batch inner calls are expanded into this map
|
||||
/// so individual tools (read/edit/bash/...) get credit.
|
||||
pub tools: BTreeMap<String, u32>,
|
||||
/// Images attached/produced in the transcript.
|
||||
pub images: u32,
|
||||
|
||||
/// Token usage rollups.
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
|
||||
/// Local-time activity histograms (hour-of-day, weekday Mon=0).
|
||||
pub hour_hist: [u32; 24],
|
||||
pub weekday_hist: [u32; 7],
|
||||
|
||||
/// Calendar dates (local, YYYY-MM-DD) on which this session was active.
|
||||
pub active_dates: Vec<String>,
|
||||
}
|
||||
|
||||
impl SessionSummary {
|
||||
pub fn total_tool_calls(&self) -> u64 {
|
||||
self.tools.values().map(|v| *v as u64).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single named, sortable metric used for "top N" lists in the report.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tally {
|
||||
pub name: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
/// The fully-computed report. Serializable so callers can render markdown, a PNG
|
||||
/// dashboard, or future JSON exports from the same source of truth.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProductivityReport {
|
||||
/// Window the report covers ("all time" today).
|
||||
pub generated_at: String,
|
||||
|
||||
// Volume ---------------------------------------------------------------
|
||||
pub total_sessions: u64,
|
||||
pub total_messages: u64,
|
||||
pub user_prompts: u64,
|
||||
pub assistant_messages: u64,
|
||||
pub total_tool_calls: u64,
|
||||
pub total_images: u64,
|
||||
|
||||
// Tokens ---------------------------------------------------------------
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
|
||||
// Human effort ---------------------------------------------------------
|
||||
pub user_chars: u64,
|
||||
pub user_words: u64,
|
||||
pub assistant_chars: u64,
|
||||
|
||||
// Time -----------------------------------------------------------------
|
||||
pub first_day: Option<String>,
|
||||
pub last_day: Option<String>,
|
||||
pub active_days: u64,
|
||||
pub span_days: u64,
|
||||
pub current_streak: u64,
|
||||
pub longest_streak: u64,
|
||||
pub busiest_day: Option<Tally>,
|
||||
pub hour_hist: [u32; 24],
|
||||
pub weekday_hist: [u32; 7],
|
||||
pub peak_hour: u8,
|
||||
pub chronotype: String,
|
||||
|
||||
// Breakdowns -----------------------------------------------------------
|
||||
pub top_projects: Vec<Tally>,
|
||||
pub distinct_projects: u64,
|
||||
pub top_tools: Vec<Tally>,
|
||||
pub top_models: Vec<Tally>,
|
||||
pub top_providers: Vec<Tally>,
|
||||
|
||||
// Derived activity buckets --------------------------------------------
|
||||
pub code_edits: u64,
|
||||
pub commands_run: u64,
|
||||
pub searches: u64,
|
||||
pub web_actions: u64,
|
||||
pub longest_session_msgs: u64,
|
||||
pub avg_session_msgs: f64,
|
||||
|
||||
// Shareable flavor -----------------------------------------------------
|
||||
pub archetype: String,
|
||||
pub archetype_blurb: String,
|
||||
pub power_score: u64,
|
||||
pub badges: Vec<String>,
|
||||
|
||||
// Meta -----------------------------------------------------------------
|
||||
pub scanned_files: u64,
|
||||
pub parse_errors: u64,
|
||||
pub scan_secs: f64,
|
||||
pub cache_hits: u64,
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Transcript scanning with an on-disk incremental cache.
|
||||
//!
|
||||
//! Scanning is the expensive part: there can be ~100k JSON transcripts totaling
|
||||
//! several GB. We keep a sidecar cache (`~/.jcode/cache/productivity/summaries.json`)
|
||||
//! keyed by `(file_len, mtime_ns)` so a re-run only re-parses changed files.
|
||||
//! Parsing of the changed set is parallelized with rayon.
|
||||
|
||||
use crate::model::SessionSummary;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, Timelike};
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
/// One cached entry: the file fingerprint plus the computed summary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CacheEntry {
|
||||
len: u64,
|
||||
mtime_ns: i128,
|
||||
summary: SessionSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct Cache {
|
||||
/// Cache format version; bump to invalidate when summary semantics change.
|
||||
#[serde(default)]
|
||||
version: u32,
|
||||
entries: HashMap<String, CacheEntry>,
|
||||
}
|
||||
|
||||
const CACHE_VERSION: u32 = 1;
|
||||
|
||||
fn cache_path() -> Result<PathBuf> {
|
||||
let dir = jcode_storage::jcode_dir()?
|
||||
.join("cache")
|
||||
.join("productivity");
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
Ok(dir.join("summaries.json"))
|
||||
}
|
||||
|
||||
fn sessions_dir() -> Result<PathBuf> {
|
||||
Ok(jcode_storage::jcode_dir()?.join("sessions"))
|
||||
}
|
||||
|
||||
/// Result of a full scan: every session summary plus scan diagnostics.
|
||||
pub struct ScanResult {
|
||||
pub summaries: Vec<SessionSummary>,
|
||||
pub scanned_files: u64,
|
||||
pub parse_errors: u64,
|
||||
pub cache_hits: u64,
|
||||
pub scan_secs: f64,
|
||||
}
|
||||
|
||||
/// Scan all session transcripts, using and refreshing the incremental cache.
|
||||
pub fn scan_all() -> Result<ScanResult> {
|
||||
let started = Instant::now();
|
||||
let dir = sessions_dir()?;
|
||||
|
||||
// Load prior cache (best-effort; ignore corruption).
|
||||
let mut cache: Cache = std::fs::read(cache_path()?)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
if cache.version != CACHE_VERSION {
|
||||
cache = Cache {
|
||||
version: CACHE_VERSION,
|
||||
entries: HashMap::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Enumerate candidate transcript files.
|
||||
let mut files: Vec<(String, u64, i128)> = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let is_json = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("json"))
|
||||
.unwrap_or(false);
|
||||
if !is_json {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let len = meta.len();
|
||||
let mtime_ns = mtime_ns(&meta);
|
||||
files.push((name, len, mtime_ns));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_hits = AtomicU64::new(0);
|
||||
let parse_errors = AtomicU64::new(0);
|
||||
|
||||
// Parse (or reuse cache) in parallel. Returns (filename, entry).
|
||||
let results: Vec<(String, CacheEntry)> = files
|
||||
.par_iter()
|
||||
.filter_map(|(name, len, mtime_ns)| {
|
||||
if let Some(prev) = cache.entries.get(name)
|
||||
&& prev.len == *len
|
||||
&& prev.mtime_ns == *mtime_ns
|
||||
{
|
||||
cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Some((name.clone(), prev.clone()));
|
||||
}
|
||||
let path = dir.join(name);
|
||||
match parse_session_file(&path) {
|
||||
Ok(summary) => Some((
|
||||
name.clone(),
|
||||
CacheEntry {
|
||||
len: *len,
|
||||
mtime_ns: *mtime_ns,
|
||||
summary,
|
||||
},
|
||||
)),
|
||||
Err(_) => {
|
||||
parse_errors.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Rebuild cache from this scan (drops entries for deleted files).
|
||||
let mut new_entries: HashMap<String, CacheEntry> = HashMap::with_capacity(results.len());
|
||||
let mut summaries: Vec<SessionSummary> = Vec::with_capacity(results.len());
|
||||
for (name, entry) in results {
|
||||
summaries.push(entry.summary.clone());
|
||||
new_entries.insert(name, entry);
|
||||
}
|
||||
let new_cache = Cache {
|
||||
version: CACHE_VERSION,
|
||||
entries: new_entries,
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&new_cache)
|
||||
&& let Ok(path) = cache_path()
|
||||
{
|
||||
let _ = std::fs::write(path, bytes);
|
||||
}
|
||||
|
||||
Ok(ScanResult {
|
||||
scanned_files: summaries.len() as u64,
|
||||
parse_errors: parse_errors.into_inner(),
|
||||
cache_hits: cache_hits.into_inner(),
|
||||
scan_secs: started.elapsed().as_secs_f64(),
|
||||
summaries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mtime_ns(meta: &std::fs::Metadata) -> i128 {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(meta.mtime() as i128) * 1_000_000_000 + (meta.mtime_nsec() as i128)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn mtime_ns(meta: &std::fs::Metadata) -> i128 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal transcript parsing
|
||||
//
|
||||
// We deliberately use a tolerant, partial deserialization instead of the full
|
||||
// `Session`/`StoredMessage` types so this crate stays dependency-light and keeps
|
||||
// working even if the canonical schema drifts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawSession {
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
provider_key: Option<String>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
messages: Vec<RawMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawMessage {
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
content: Vec<RawBlock>,
|
||||
#[serde(default)]
|
||||
timestamp: Option<String>,
|
||||
#[serde(default)]
|
||||
token_usage: Option<RawTokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawTokenUsage {
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
output_tokens: u64,
|
||||
#[serde(default)]
|
||||
cache_read_input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum RawBlock {
|
||||
Text {
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
input: serde_json::Value,
|
||||
},
|
||||
Image {},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
fn parse_session_file(path: &Path) -> Result<SessionSummary> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let raw: RawSession = serde_json::from_slice(&bytes)?;
|
||||
Ok(summarize(raw))
|
||||
}
|
||||
|
||||
fn summarize(raw: RawSession) -> SessionSummary {
|
||||
let mut s = SessionSummary {
|
||||
created_at: raw.created_at.clone(),
|
||||
updated_at: raw.updated_at.clone(),
|
||||
working_dir: raw.working_dir.clone(),
|
||||
project: raw.working_dir.as_deref().map(project_name),
|
||||
provider_key: raw.provider_key,
|
||||
model: raw.model,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut active_dates = std::collections::BTreeSet::new();
|
||||
let record_time =
|
||||
|ts: &str, s: &mut SessionSummary, dates: &mut std::collections::BTreeSet<String>| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
|
||||
let local = dt.with_timezone(&Local);
|
||||
s.hour_hist[local.hour() as usize] += 1;
|
||||
s.weekday_hist[local.weekday().num_days_from_monday() as usize] += 1;
|
||||
dates.insert(local.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
};
|
||||
|
||||
for msg in &raw.messages {
|
||||
let role = msg.role.as_deref().unwrap_or("");
|
||||
match role {
|
||||
"user" => s.user_msgs += 1,
|
||||
"assistant" => s.assistant_msgs += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(ts) = &msg.timestamp {
|
||||
if s.first_ts.is_none() {
|
||||
s.first_ts = Some(ts.clone());
|
||||
}
|
||||
s.last_ts = Some(ts.clone());
|
||||
record_time(ts, &mut s, &mut active_dates);
|
||||
}
|
||||
|
||||
if let Some(tu) = &msg.token_usage {
|
||||
s.input_tokens += tu.input_tokens;
|
||||
s.output_tokens += tu.output_tokens;
|
||||
s.cache_read_tokens += tu.cache_read_input_tokens.unwrap_or(0);
|
||||
s.cache_creation_tokens += tu.cache_creation_input_tokens.unwrap_or(0);
|
||||
}
|
||||
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
RawBlock::Text { text } => {
|
||||
let len = text.chars().count() as u64;
|
||||
if role == "user" {
|
||||
// Skip giant tool-result-ish or reminder blobs from the
|
||||
// human "typed" proxy; keep it to real prompts.
|
||||
if !text.trim_start().starts_with("<system-reminder>") {
|
||||
s.user_chars += len;
|
||||
}
|
||||
} else if role == "assistant" {
|
||||
s.assistant_chars += len;
|
||||
}
|
||||
}
|
||||
RawBlock::ToolUse { name, input } => {
|
||||
count_tool(&mut s.tools, name, input);
|
||||
}
|
||||
RawBlock::Image {} => s.images += 1,
|
||||
RawBlock::Other => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to header timestamps for the activity calendar when individual
|
||||
// messages lacked timestamps (common for imported transcripts).
|
||||
if active_dates.is_empty()
|
||||
&& let Some(ts) = raw.updated_at.as_deref().or(raw.created_at.as_deref())
|
||||
&& let Ok(dt) = DateTime::parse_from_rfc3339(ts)
|
||||
{
|
||||
let local = dt.with_timezone(&Local);
|
||||
s.hour_hist[local.hour() as usize] += 1;
|
||||
s.weekday_hist[local.weekday().num_days_from_monday() as usize] += 1;
|
||||
active_dates.insert(local.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
|
||||
s.active_dates = active_dates.into_iter().collect();
|
||||
s
|
||||
}
|
||||
|
||||
/// Expand a single tool invocation into the histogram. `batch` is special-cased
|
||||
/// so the inner tool calls get individual credit.
|
||||
fn count_tool(tools: &mut BTreeMap<String, u32>, name: &str, input: &serde_json::Value) {
|
||||
let canonical = canonical_tool_name(name);
|
||||
*tools.entry(canonical.to_string()).or_insert(0) += 1;
|
||||
|
||||
if canonical == "batch"
|
||||
&& let Some(calls) = input.get("tool_calls").and_then(|v| v.as_array())
|
||||
{
|
||||
for call in calls {
|
||||
if let Some(inner) = call.get("tool").and_then(|v| v.as_str()) {
|
||||
let inner_canon = canonical_tool_name(inner);
|
||||
*tools.entry(inner_canon.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize legacy/alias tool names to their canonical identity.
|
||||
fn canonical_tool_name(name: &str) -> &str {
|
||||
match name {
|
||||
"file_read" => "read",
|
||||
"file_write" => "write",
|
||||
"file_edit" => "edit",
|
||||
"file_grep" => "grep",
|
||||
"todowrite" => "todo",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_name(working_dir: &str) -> String {
|
||||
let trimmed = working_dir.trim_end_matches('/');
|
||||
Path::new(trimmed)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(trimmed)
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::model::SessionSummary;
|
||||
use crate::{render_markdown, render_png, render_svg, report_from_summaries};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn summary(project: &str, user: u32, asst: u32, tools: &[(&str, u32)]) -> SessionSummary {
|
||||
let mut t = BTreeMap::new();
|
||||
for (k, v) in tools {
|
||||
t.insert(k.to_string(), *v);
|
||||
}
|
||||
SessionSummary {
|
||||
project: Some(project.to_string()),
|
||||
working_dir: Some(format!("/home/u/{project}")),
|
||||
provider_key: Some("openai".to_string()),
|
||||
model: Some("gpt-5.5".to_string()),
|
||||
user_msgs: user,
|
||||
assistant_msgs: asst,
|
||||
user_chars: (user as u64) * 50,
|
||||
assistant_chars: (asst as u64) * 200,
|
||||
tools: t,
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 2000,
|
||||
active_dates: vec!["2026-06-01".to_string(), "2026-06-02".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_basic_totals() {
|
||||
let summaries = vec![
|
||||
summary("alpha", 3, 3, &[("read", 5), ("edit", 2), ("bash", 4)]),
|
||||
summary("alpha", 2, 2, &[("read", 1), ("apply_patch", 3)]),
|
||||
summary("beta", 5, 5, &[("agentgrep", 7), ("browser", 1)]),
|
||||
];
|
||||
let r = report_from_summaries(summaries);
|
||||
|
||||
assert_eq!(r.total_sessions, 3);
|
||||
assert_eq!(r.user_prompts, 10);
|
||||
assert_eq!(r.assistant_messages, 10);
|
||||
// read 6 + edit 2 + bash 4 + apply_patch 3 + agentgrep 7 + browser 1 = 23
|
||||
assert_eq!(r.total_tool_calls, 23);
|
||||
// edit 2 + apply_patch 3 = 5
|
||||
assert_eq!(r.code_edits, 5);
|
||||
assert_eq!(r.commands_run, 4);
|
||||
assert_eq!(r.searches, 7);
|
||||
assert_eq!(r.web_actions, 1);
|
||||
assert_eq!(r.distinct_projects, 2);
|
||||
assert!(r.power_score > 0);
|
||||
assert!(!r.archetype.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_lists_sorted_desc() {
|
||||
let summaries = vec![
|
||||
summary("alpha", 1, 1, &[("read", 10)]),
|
||||
summary("alpha", 1, 1, &[("read", 5)]),
|
||||
summary("gamma", 1, 1, &[("bash", 1)]),
|
||||
];
|
||||
let r = report_from_summaries(summaries);
|
||||
assert_eq!(r.top_projects.first().unwrap().name, "alpha");
|
||||
assert_eq!(r.top_projects.first().unwrap().count, 2);
|
||||
assert_eq!(r.top_tools.first().unwrap().name, "read");
|
||||
assert_eq!(r.top_tools.first().unwrap().count, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaks_and_active_days_dedup() {
|
||||
let mut s = summary("alpha", 1, 1, &[("read", 1)]);
|
||||
s.active_dates = vec![
|
||||
"2026-05-10".to_string(),
|
||||
"2026-05-11".to_string(),
|
||||
"2026-05-12".to_string(),
|
||||
"2026-05-20".to_string(),
|
||||
];
|
||||
let r = report_from_summaries(vec![s]);
|
||||
assert_eq!(r.active_days, 4);
|
||||
assert_eq!(r.longest_streak, 3);
|
||||
assert_eq!(r.first_day.as_deref(), Some("2026-05-10"));
|
||||
assert_eq!(r.last_day.as_deref(), Some("2026-05-20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_markdown_and_png() {
|
||||
let summaries = vec![summary(
|
||||
"alpha",
|
||||
4,
|
||||
4,
|
||||
&[("read", 5), ("edit", 3), ("bash", 2)],
|
||||
)];
|
||||
let r = report_from_summaries(summaries);
|
||||
|
||||
let md = render_markdown(&r);
|
||||
assert!(md.contains("Productivity Report"));
|
||||
assert!(md.contains("Power Score"));
|
||||
|
||||
let svg = render_svg(&r);
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(svg.contains("</text>"));
|
||||
|
||||
// PNG rendering depends on system fonts; ensure it produces a valid PNG.
|
||||
let png = render_png(&r).expect("png render");
|
||||
assert!(png.len() > 1000, "png too small: {}", png.len());
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_report_is_safe() {
|
||||
let r = report_from_summaries(vec![]);
|
||||
assert_eq!(r.total_sessions, 0);
|
||||
let md = render_markdown(&r);
|
||||
assert!(md.contains("Productivity Report"));
|
||||
let png = render_png(&r).expect("png render");
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
}
|
||||
Reference in New Issue
Block a user