Skip to content

Commit f378d8b

Browse files
MarkShawn2020claude
andcommitted
feat(annual-report): add command count stats and improve share card UI
- Add total_commands field to annual report data structure - Extract command usage from session history as fallback stats - Improve share card with stats period badge and 3-column layout - Add QR code asset for sharing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 26f4068 commit f378d8b

4 files changed

Lines changed: 176 additions & 58 deletions

File tree

public/lovcode-qrcode.png

11.8 KB
Loading

src-tauri/src/lib.rs

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3731,6 +3731,7 @@ pub struct TopCommand {
37313731
pub struct AnnualReport2025 {
37323732
pub total_sessions: usize,
37333733
pub total_messages: usize,
3734+
pub total_commands: usize,
37343735
pub active_days: usize,
37353736
pub first_chat_date: Option<String>,
37363737
pub last_chat_date: Option<String>,
@@ -3835,6 +3836,8 @@ async fn get_annual_report_2025() -> Result<AnnualReport2025, String> {
38353836
let mut total_sessions = 0usize;
38363837
let mut total_messages = 0usize;
38373838
let mut project_stats: HashMap<String, (String, usize, usize)> = HashMap::new(); // id -> (path, sessions, messages)
3839+
let mut command_counts: HashMap<String, usize> = HashMap::new(); // command -> count (fallback)
3840+
let command_pattern = regex::Regex::new(r"<command-name>(/[^<]+)</command-name>").ok();
38383841

38393842
if projects_dir.exists() {
38403843
if let Ok(entries) = fs::read_dir(&projects_dir) {
@@ -3889,6 +3892,19 @@ async fn get_annual_report_2025() -> Result<AnnualReport2025, String> {
38893892
if parsed.get("type").and_then(|t| t.as_str()) != Some("meta") {
38903893
msg_count += 1;
38913894
}
3895+
// Extract commands from assistant messages (for fallback stats)
3896+
if let Some(pattern) = &command_pattern {
3897+
if let Some(text) = parsed.get("message").and_then(|m| {
3898+
m.get("content").and_then(|c| c.as_str())
3899+
}) {
3900+
for cap in pattern.captures_iter(text) {
3901+
if let Some(cmd_match) = cap.get(1) {
3902+
let cmd = cmd_match.as_str().trim_start_matches('/').to_string();
3903+
*command_counts.entry(cmd).or_insert(0) += 1;
3904+
}
3905+
}
3906+
}
3907+
}
38923908
}
38933909
}
38943910

@@ -3920,26 +3936,61 @@ async fn get_annual_report_2025() -> Result<AnnualReport2025, String> {
39203936
message_count: *messages,
39213937
});
39223938

3923-
// Get top commands from command-stats index
3939+
// Get top commands from command-stats index (aggregate weekly data) or fallback to extracted
39243940
let mut top_commands: Vec<TopCommand> = Vec::new();
3925-
let index_path = get_claude_dir().join("command-stats.json");
3926-
if index_path.exists() {
3927-
if let Ok(content) = fs::read_to_string(&index_path) {
3928-
if let Ok(stats) = serde_json::from_str::<HashMap<String, usize>>(&content) {
3929-
let mut sorted: Vec<_> = stats.into_iter().collect();
3930-
sorted.sort_by(|a, b| b.1.cmp(&a.1));
3931-
top_commands = sorted
3932-
.into_iter()
3933-
.take(5)
3934-
.map(|(name, count)| TopCommand { name, count })
3935-
.collect();
3941+
let stats_path = get_command_stats_path();
3942+
let mut use_fallback = true;
3943+
3944+
if stats_path.exists() {
3945+
if let Ok(content) = fs::read_to_string(&stats_path) {
3946+
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
3947+
if let Some(commands) = parsed.get("commands").and_then(|v| v.as_object()) {
3948+
let mut aggregated: HashMap<String, usize> = HashMap::new();
3949+
for (cmd_name, week_data) in commands {
3950+
if let Some(weeks) = week_data.as_object() {
3951+
let total: usize = weeks
3952+
.values()
3953+
.filter_map(|v| v.as_u64())
3954+
.map(|n| n as usize)
3955+
.sum();
3956+
aggregated.insert(cmd_name.clone(), total);
3957+
}
3958+
}
3959+
if !aggregated.is_empty() {
3960+
let mut sorted: Vec<_> = aggregated.into_iter().collect();
3961+
sorted.sort_by(|a, b| b.1.cmp(&a.1));
3962+
top_commands = sorted
3963+
.into_iter()
3964+
.take(5)
3965+
.map(|(name, count)| TopCommand { name, count })
3966+
.collect();
3967+
use_fallback = false;
3968+
}
3969+
}
39363970
}
39373971
}
39383972
}
39393973

3974+
// Fallback: use command counts extracted from session files
3975+
if use_fallback && !command_counts.is_empty() {
3976+
let mut sorted: Vec<_> = command_counts.into_iter().collect();
3977+
sorted.sort_by(|a, b| b.1.cmp(&a.1));
3978+
top_commands = sorted
3979+
.into_iter()
3980+
.take(5)
3981+
.map(|(name, count)| TopCommand { name, count })
3982+
.collect();
3983+
}
3984+
3985+
// Count local commands
3986+
let total_commands = list_local_commands()
3987+
.map(|cmds| cmds.len())
3988+
.unwrap_or(0);
3989+
39403990
Ok(AnnualReport2025 {
39413991
total_sessions,
39423992
total_messages,
3993+
total_commands,
39433994
active_days: daily_activity.len(),
39443995
first_chat_date: first_date,
39453996
last_chat_date: last_date,
@@ -4429,6 +4480,11 @@ fn write_file(path: String, content: String) -> Result<(), String> {
44294480
fs::write(&path, content).map_err(|e| e.to_string())
44304481
}
44314482

4483+
#[tauri::command]
4484+
fn write_binary_file(path: String, data: Vec<u8>) -> Result<(), String> {
4485+
fs::write(&path, data).map_err(|e| e.to_string())
4486+
}
4487+
44324488
#[tauri::command]
44334489
fn update_mcp_env(server_name: String, env_key: String, env_value: String) -> Result<(), String> {
44344490
let claude_json_path = get_claude_json_path();
@@ -6120,6 +6176,7 @@ pub fn run() {
61206176
get_mcp_config_path,
61216177
get_home_dir,
61226178
write_file,
6179+
write_binary_file,
61236180
update_mcp_env,
61246181
update_settings_env,
61256182
delete_settings_env,

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export interface TopCommand {
242242
export interface AnnualReport2025 {
243243
total_sessions: number;
244244
total_messages: number;
245+
total_commands: number;
245246
active_days: number;
246247
first_chat_date: string | null;
247248
last_chat_date: string | null;

src/views/AnnualReport/AnnualReport2025.tsx

Lines changed: 106 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { createPortal } from "react-dom";
33
import { motion, AnimatePresence } from "framer-motion";
44
import { Cross2Icon, ChevronDownIcon, DownloadIcon } from "@radix-ui/react-icons";
55
import { domToCanvas } from "modern-screenshot";
6+
import { save } from "@tauri-apps/plugin-dialog";
7+
import { invoke } from "@tauri-apps/api/core";
8+
import { revealItemInDir } from "@tauri-apps/plugin-opener";
69
import type { AnnualReport2025 as AnnualReportData } from "../../types";
710
import { useInvokeQuery } from "../../hooks";
811

@@ -482,48 +485,89 @@ function ShareableCard({ report }: { report: AnnualReportData }) {
482485
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
483486
}}
484487
>
485-
{/* 2x2 Core Stats Grid - Unified warm colors */}
488+
{/* Stats Period Badge */}
489+
<div
490+
style={{
491+
textAlign: "center",
492+
marginBottom: 12,
493+
paddingBottom: 12,
494+
borderBottom: "1px solid rgba(216,90,42,0.1)",
495+
}}
496+
>
497+
<span
498+
style={{
499+
fontSize: 10,
500+
color: "#AA7A6A",
501+
background: "rgba(216,90,42,0.08)",
502+
padding: "4px 12px",
503+
borderRadius: 10,
504+
fontWeight: 500,
505+
}}
506+
>
507+
统计周期:{report.first_chat_date || "2025.01"} - {report.last_chat_date || "2025.12"}
508+
</span>
509+
</div>
510+
511+
{/* 3-column Core Stats Grid */}
486512
<div
487513
style={{
488514
display: "grid",
489-
gridTemplateColumns: "1fr 1fr",
490-
gap: 12,
515+
gridTemplateColumns: "1fr 1fr 1fr",
516+
gap: 8,
491517
}}
492518
>
493519
{/* Conversations */}
494-
<div style={{ textAlign: "center", padding: 12 }}>
495-
<div style={{ fontSize: 11, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>对话总数</div>
496-
<div style={{ fontSize: 38, fontWeight: 800, color: "#D85A2A", lineHeight: 1 }}>
520+
<div style={{ textAlign: "center", padding: 10 }}>
521+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>对话</div>
522+
<div style={{ fontSize: 28, fontWeight: 800, color: "#D85A2A", lineHeight: 1 }}>
497523
{report.total_sessions.toLocaleString()}
498524
</div>
499-
<div style={{ fontSize: 11, color: "#AA7A6A", marginTop: 2 }}></div>
525+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
500526
</div>
501527

502528
{/* Messages */}
503-
<div style={{ textAlign: "center", padding: 12 }}>
504-
<div style={{ fontSize: 11, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>消息数量</div>
505-
<div style={{ fontSize: 38, fontWeight: 800, color: "#E8734A", lineHeight: 1 }}>
529+
<div style={{ textAlign: "center", padding: 10 }}>
530+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>消息</div>
531+
<div style={{ fontSize: 28, fontWeight: 800, color: "#E8734A", lineHeight: 1 }}>
506532
{report.total_messages.toLocaleString()}
507533
</div>
508-
<div style={{ fontSize: 11, color: "#AA7A6A", marginTop: 2 }}></div>
534+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
535+
</div>
536+
537+
{/* Commands */}
538+
<div style={{ textAlign: "center", padding: 10 }}>
539+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>命令</div>
540+
<div style={{ fontSize: 28, fontWeight: 800, color: "#D85A2A", lineHeight: 1 }}>
541+
{report.total_commands.toLocaleString()}
542+
</div>
543+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
509544
</div>
510545

511546
{/* Active Days */}
512-
<div style={{ textAlign: "center", padding: 12 }}>
513-
<div style={{ fontSize: 11, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>活跃天数</div>
514-
<div style={{ fontSize: 38, fontWeight: 800, color: "#D85A2A", lineHeight: 1 }}>
547+
<div style={{ textAlign: "center", padding: 10 }}>
548+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>活跃</div>
549+
<div style={{ fontSize: 28, fontWeight: 800, color: "#E8734A", lineHeight: 1 }}>
515550
{report.active_days}
516551
</div>
517-
<div style={{ fontSize: 11, color: "#AA7A6A", marginTop: 2 }}></div>
552+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
518553
</div>
519554

520555
{/* Projects */}
521-
<div style={{ textAlign: "center", padding: 12 }}>
522-
<div style={{ fontSize: 11, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>项目数量</div>
523-
<div style={{ fontSize: 38, fontWeight: 800, color: "#E8734A", lineHeight: 1 }}>
556+
<div style={{ textAlign: "center", padding: 10 }}>
557+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>项目</div>
558+
<div style={{ fontSize: 28, fontWeight: 800, color: "#D85A2A", lineHeight: 1 }}>
524559
{report.total_projects}
525560
</div>
526-
<div style={{ fontSize: 11, color: "#AA7A6A", marginTop: 2 }}></div>
561+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
562+
</div>
563+
564+
{/* Longest Streak */}
565+
<div style={{ textAlign: "center", padding: 10 }}>
566+
<div style={{ fontSize: 10, color: "#996B5B", marginBottom: 4, fontWeight: 600 }}>连续</div>
567+
<div style={{ fontSize: 28, fontWeight: 800, color: "#E8734A", lineHeight: 1 }}>
568+
{report.longest_streak}
569+
</div>
570+
<div style={{ fontSize: 10, color: "#AA7A6A", marginTop: 2 }}></div>
527571
</div>
528572
</div>
529573

@@ -574,22 +618,17 @@ function ShareableCard({ report }: { report: AnnualReportData }) {
574618
marginBottom: 16,
575619
}}
576620
>
577-
{/* Secondary Stats Row */}
621+
{/* Secondary Stats Row - Peak Time Info */}
578622
<div
579623
style={{
580624
display: "grid",
581-
gridTemplateColumns: "1fr 1fr 1fr",
582-
gap: 8,
625+
gridTemplateColumns: "1fr 1fr",
626+
gap: 12,
583627
paddingBottom: 14,
584628
borderBottom: "1px solid rgba(255,255,255,0.1)",
585629
marginBottom: 14,
586630
}}
587631
>
588-
<div style={{ textAlign: "center" }}>
589-
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.6)", marginBottom: 2 }}>最长连续</div>
590-
<div style={{ fontSize: 24, fontWeight: 800, color: "#FFFFFF", lineHeight: 1 }}>{report.longest_streak}</div>
591-
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)" }}></div>
592-
</div>
593632
<div style={{ textAlign: "center" }}>
594633
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.6)", marginBottom: 2 }}>活跃时段</div>
595634
<div style={{ fontSize: 24, fontWeight: 800, color: "#FFFFFF", lineHeight: 1 }}>{report.peak_hour}:00</div>
@@ -639,18 +678,24 @@ function ShareableCard({ report }: { report: AnnualReportData }) {
639678
)}
640679
</div>
641680

642-
{/* Footer - Compact */}
643-
<div style={{ textAlign: "center" }}>
644-
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, marginBottom: 4 }}>
681+
{/* Footer - with QR code */}
682+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
683+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
645684
<img
646685
src="/logo.png"
647686
alt="Lovcode"
648687
style={{ width: 20, height: 20, borderRadius: 4 }}
649688
/>
650-
<span style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>Lovcode</span>
651-
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.4)" }}>|</span>
652-
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.5)" }}>Your Vibe Coding Hub</span>
689+
<div>
690+
<div style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>Lovcode</div>
691+
<div style={{ fontSize: 9, color: "rgba(255,255,255,0.5)" }}>Your Vibe Coding Hub</div>
692+
</div>
653693
</div>
694+
<img
695+
src="/lovcode-qrcode.png"
696+
alt="QR Code"
697+
style={{ width: 48, height: 48, borderRadius: 4, background: "#FFFFFF", padding: 2 }}
698+
/>
654699
</div>
655700
</div>
656701
</div>
@@ -720,7 +765,7 @@ export function AnnualReport2025({ onClose }: AnnualReport2025Props) {
720765
);
721766

722767
// Share functionality - uses domToCanvas with Portal (no UI flicker)
723-
// Inspired by lovshot: clone DOM in memory, use scrollWidth/scrollHeight for original size
768+
// Uses Tauri save dialog for proper file saving without Finder popup
724769
const handleShare = useCallback(async () => {
725770
const captureEl = shareCardRef.current;
726771
if (!captureEl || !report) return;
@@ -734,7 +779,7 @@ export function AnnualReport2025({ onClose }: AnnualReport2025Props) {
734779
// domToCanvas clones DOM in memory - no UI impact
735780
const canvas = await domToCanvas(captureEl, {
736781
scale: 2,
737-
backgroundColor: "#F9F9F7",
782+
backgroundColor: "#C54B24",
738783
width: originalWidth,
739784
height: originalHeight,
740785
style: {
@@ -745,17 +790,32 @@ export function AnnualReport2025({ onClose }: AnnualReport2025Props) {
745790
},
746791
});
747792

748-
// Convert canvas to blob and download
749-
canvas.toBlob((blob) => {
750-
if (blob) {
751-
const url = URL.createObjectURL(blob);
752-
const link = document.createElement("a");
753-
link.download = "lovcode-2025-report.png";
754-
link.href = url;
755-
link.click();
756-
URL.revokeObjectURL(url);
757-
}
758-
}, "image/png");
793+
// Convert canvas to blob
794+
const blob = await new Promise<Blob | null>((resolve) => {
795+
canvas.toBlob(resolve, "image/png");
796+
});
797+
798+
if (!blob) {
799+
throw new Error("Failed to create blob");
800+
}
801+
802+
// Use Tauri save dialog
803+
const path = await save({
804+
defaultPath: "lovcode-2025-report.png",
805+
filters: [{ name: "PNG Image", extensions: ["png"] }],
806+
});
807+
808+
if (path) {
809+
// Convert blob to Uint8Array
810+
const arrayBuffer = await blob.arrayBuffer();
811+
const data = Array.from(new Uint8Array(arrayBuffer));
812+
813+
// Write file using Tauri command
814+
await invoke("write_binary_file", { path, data });
815+
816+
// Reveal in Finder
817+
await revealItemInDir(path);
818+
}
759819
} catch (err) {
760820
console.error("Failed to generate image:", err);
761821
} finally {

0 commit comments

Comments
 (0)