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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+20
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 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")
}
+82
View File
@@ -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()
}
+134
View File
@@ -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,
}
+369
View File
@@ -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()
}
+114
View File
@@ -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");
}