Skip to main content

lean_ctx/core/
wrapped_svg.rs

1//! Shareable SVG "Wrapped" card.
2//!
3//! Renders [`WrappedReport`] as a self-contained 1200x630 SVG (the social/OG image
4//! size). It is pure string building — no external crates, fonts, or assets — so the
5//! output is portable, diff-friendly, and can be posted directly or rasterised to PNG
6//! by any standard SVG tool. All data-derived text is XML-escaped.
7
8use crate::core::wrapped::{format_tokens, WrappedReport};
9
10/// Social/OG card dimensions.
11const CARD_W: u32 = 1200;
12const CARD_H: u32 = 630;
13
14impl WrappedReport {
15    /// Renders a polished, dependency-free SVG share card.
16    pub fn to_svg(&self) -> String {
17        let period_label = match self.period.as_str() {
18            "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
19            "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
20            _ => "All Time".to_string(),
21        };
22
23        let saved = format_tokens(self.tokens_saved);
24        let cost = format!("${:.2}", self.cost_avoided_usd);
25        let est = if self.pricing_estimated {
26            " (est.)"
27        } else {
28            ""
29        };
30        let secondary = self.svg_secondary_metrics();
31        // Model line is only meaningful when a model was shared (older cards). Minimal cards omit it.
32        let model_line = if self.model_key.is_empty() {
33            String::new()
34        } else {
35            format!(
36                r##"  <text x="70" y="606" fill="#475569" font-size="17">priced at {}{}</text>"##,
37                escape(&self.model_key),
38                est
39            )
40        };
41
42        let spark = self.svg_sparkline();
43        let top = self.svg_top_commands();
44        let bounce_note = if self.bounce_tokens > 0 {
45            format!(
46                " - {} bounce",
47                crate::core::wrapped::format_tokens(self.bounce_tokens)
48            )
49        } else {
50            String::new()
51        };
52
53        format!(
54            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{CARD_W}" height="{CARD_H}" viewBox="0 0 {CARD_W} {CARD_H}" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
55  <defs>
56    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
57      <stop offset="0" stop-color="#0b1020"/>
58      <stop offset="1" stop-color="#131a2e"/>
59    </linearGradient>
60    <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
61      <stop offset="0" stop-color="#34d399"/>
62      <stop offset="1" stop-color="#22d3ee"/>
63    </linearGradient>
64  </defs>
65  <rect width="{CARD_W}" height="{CARD_H}" fill="url(#bg)"/>
66  <rect x="0" y="0" width="{CARD_W}" height="8" fill="url(#accent)"/>
67
68  <text x="70" y="92" fill="#e5e7eb" font-size="34" font-weight="700">lean-ctx <tspan fill="#34d399">Wrapped</tspan></text>
69  <text x="70" y="130" fill="#94a3b8" font-size="24">{period}</text>
70
71  <text x="70" y="300" fill="#34d399" font-size="138" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{saved}</text>
72  <text x="76" y="346" fill="#94a3b8" font-size="26">tokens saved</text>
73
74  <text x="730" y="252" fill="#22d3ee" font-size="84" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{cost}</text>
75  <text x="734" y="292" fill="#94a3b8" font-size="24">cost avoided{est}</text>
76
77{secondary}
78{spark}
79{top}
80  <text x="70" y="582" fill="#64748b" font-size="19">Savings = measured original - compressed{bounce_note} tokens · USD = upper bound</text>
81{model_line}
82  <text x="1130" y="592" text-anchor="end" fill="#34d399" font-size="26" font-weight="700">leanctx.com</text>
83</svg>"##,
84            period = escape(&period_label),
85        )
86    }
87
88    /// The secondary metric row: compression + energy always; commands/sessions only when present
89    /// (older or local cards). Energy is derived from tokens — the same J/token basis as the
90    /// community metrics page — so showing it shares no extra data. Laid out left-to-right so a
91    /// minimal card (just compression + energy) looks balanced and a rich one fills the row.
92    fn svg_secondary_metrics(&self) -> String {
93        let mut items: Vec<(String, &str)> = vec![
94            (format!("{:.1}%", self.compression_rate_pct), "compression"),
95            (
96                crate::core::energy::format_for_tokens(self.tokens_saved),
97                "energy saved",
98            ),
99        ];
100        if self.total_commands > 0 {
101            items.push((self.total_commands.to_string(), "commands"));
102        }
103        if self.sessions_count > 0 {
104            items.push((self.sessions_count.to_string(), "sessions"));
105        }
106        let xs = [70, 360, 650, 940];
107        let mut out = String::new();
108        for (i, (val, label)) in items.iter().take(xs.len()).enumerate() {
109            let x = xs[i];
110            out.push_str(&format!(
111                "  <text x=\"{x}\" y=\"412\" fill=\"#e5e7eb\" font-size=\"44\" font-weight=\"700\" font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\">{val}</text>\n  <text x=\"{lx}\" y=\"442\" fill=\"#94a3b8\" font-size=\"22\">{label}</text>",
112                lx = x + 2,
113                val = escape(val),
114            ));
115            if i + 1 < items.len() {
116                out.push('\n');
117            }
118        }
119        out
120    }
121
122    /// A subtle accent-gradient sparkline of daily savings. Empty when there is not
123    /// enough history to be meaningful (fewer than two non-zero days).
124    fn svg_sparkline(&self) -> String {
125        let vals = &self.daily_savings;
126        if vals.iter().filter(|v| **v > 0).count() < 2 {
127            return String::new();
128        }
129        let max = (*vals.iter().max().unwrap_or(&1)).max(1) as f64;
130        let (x0, x1) = (70.0_f64, 1130.0_f64);
131        let baseline = 515.0_f64;
132        let height = 55.0_f64;
133        let n = vals.len().max(2);
134        let dx = (x1 - x0) / (n as f64 - 1.0);
135        let mut points = String::new();
136        for (i, v) in vals.iter().enumerate() {
137            let x = x0 + dx * i as f64;
138            let y = baseline - (*v as f64 / max) * height;
139            points.push_str(&format!("{x:.1},{y:.1} "));
140        }
141        format!(
142            "  <polyline fill=\"none\" stroke=\"url(#accent)\" stroke-width=\"3\" stroke-linejoin=\"round\" stroke-linecap=\"round\" points=\"{}\"/>",
143            points.trim()
144        )
145    }
146
147    /// The top three commands as a single muted line. Empty when no command data.
148    fn svg_top_commands(&self) -> String {
149        if self.top_commands.is_empty() {
150            return String::new();
151        }
152        let joined = self
153            .top_commands
154            .iter()
155            .take(3)
156            .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
157            .collect::<Vec<_>>()
158            .join("    ·    ");
159        format!(
160            "  <text x=\"70\" y=\"548\" fill=\"#cbd5e1\" font-size=\"22\">top  {}</text>",
161            escape(&joined)
162        )
163    }
164}
165
166/// Minimal XML text escaping for data-derived strings.
167fn escape(s: &str) -> String {
168    s.replace('&', "&amp;")
169        .replace('<', "&lt;")
170        .replace('>', "&gt;")
171        .replace('"', "&quot;")
172        .replace('\'', "&apos;")
173}
174
175#[cfg(test)]
176mod tests {
177    use crate::core::wrapped::WrappedReport;
178
179    fn sample() -> WrappedReport {
180        WrappedReport {
181            period: "all".into(),
182            tokens_saved: 348_300_000,
183            tokens_input: 580_000_000,
184            cost_avoided_usd: 870.81,
185            total_commands: 17_055,
186            sessions_count: 67,
187            top_commands: vec![
188                ("ctx_search".into(), 100, 60.0),
189                ("cli_grep <x>".into(), 80, 85.0),
190                ("cli_shell".into(), 50, 37.0),
191            ],
192            compression_rate_pct: 60.2,
193            files_touched: 1_234,
194            daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
195            bounce_tokens: 0,
196            model_key: "claude-3.5-sonnet".into(),
197            pricing_estimated: false,
198            percentile: Some(99),
199        }
200    }
201
202    #[test]
203    fn svg_is_well_formed_and_branded() {
204        let svg = sample().to_svg();
205        assert!(svg.starts_with("<svg"), "must be an SVG document");
206        assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
207        assert!(svg.contains("leanctx.com"), "must carry the brand footer");
208        assert!(svg.contains("Wrapped"));
209        assert!(svg.contains("tokens saved"));
210        // Headline metric value is rendered.
211        assert!(svg.contains("348.3M"), "must render formatted tokens saved");
212    }
213
214    #[test]
215    fn svg_states_methodology_and_model() {
216        let svg = sample().to_svg();
217        assert!(
218            svg.contains("upper bound"),
219            "must state USD is an upper bound"
220        );
221        assert!(
222            svg.contains("claude-3.5-sonnet"),
223            "must name the pricing model"
224        );
225    }
226
227    #[test]
228    fn svg_escapes_command_names() {
229        let svg = sample().to_svg();
230        // The command "cli_grep <x>" must not leak a raw '<x>' that would break XML.
231        assert!(
232            svg.contains("cli_grep &lt;x&gt;"),
233            "command names must be escaped"
234        );
235    }
236
237    #[test]
238    fn svg_minimal_card_shows_energy_and_omits_empty_fields() {
239        // A card published by a current (minimal) client carries no command/session counts or
240        // model — the card must still render cleanly: energy + compression, nothing zeroed.
241        let mut r = sample();
242        r.total_commands = 0;
243        r.sessions_count = 0;
244        r.model_key = String::new();
245        r.top_commands = vec![];
246        let svg = r.to_svg();
247        assert!(
248            svg.contains(">energy saved<"),
249            "energy is always shown:\n{svg}"
250        );
251        assert!(svg.contains(">compression<"), "compression is always shown");
252        assert!(!svg.contains(">commands<"), "no commands label when zero");
253        assert!(!svg.contains(">sessions<"), "no sessions label when zero");
254        assert!(!svg.contains("priced at"), "no model line when model empty");
255    }
256
257    #[test]
258    fn svg_omits_sparkline_without_history() {
259        let mut r = sample();
260        r.daily_savings = vec![0];
261        let svg = r.to_svg();
262        assert!(
263            !svg.contains("<polyline"),
264            "no sparkline without enough history"
265        );
266        // Card still renders the rest.
267        assert!(svg.contains("</svg>"));
268    }
269}