wrapped-claude 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wrapped_claude-0.1.0/LICENSE +22 -0
- wrapped_claude-0.1.0/PKG-INFO +122 -0
- wrapped_claude-0.1.0/README.md +103 -0
- wrapped_claude-0.1.0/pyproject.toml +33 -0
- wrapped_claude-0.1.0/setup.cfg +4 -0
- wrapped_claude-0.1.0/wrapped_claude/__init__.py +5 -0
- wrapped_claude-0.1.0/wrapped_claude/cli.py +447 -0
- wrapped_claude-0.1.0/wrapped_claude/fonts/SpaceGrotesk-500.woff2 +0 -0
- wrapped_claude-0.1.0/wrapped_claude/fonts/SpaceGrotesk-700.woff2 +0 -0
- wrapped_claude-0.1.0/wrapped_claude/fonts/SpaceMono-700.woff2 +0 -0
- wrapped_claude-0.1.0/wrapped_claude/template.html +270 -0
- wrapped_claude-0.1.0/wrapped_claude.egg-info/PKG-INFO +122 -0
- wrapped_claude-0.1.0/wrapped_claude.egg-info/SOURCES.txt +14 -0
- wrapped_claude-0.1.0/wrapped_claude.egg-info/dependency_links.txt +1 -0
- wrapped_claude-0.1.0/wrapped_claude.egg-info/entry_points.txt +2 -0
- wrapped_claude-0.1.0/wrapped_claude.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kafui Hounkpatin (original "My Claude Wrapped")
|
|
4
|
+
Copyright (c) 2026 Salem Gnandi (neo-brutalist adaptation)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wrapped-claude
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI).
|
|
5
|
+
Author: Salem Gnandi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Salemgnk/claude-wrapped
|
|
8
|
+
Project-URL: Original, https://github.com/HKafuiEPI/my_claude_wrapped
|
|
9
|
+
Keywords: claude,claude-code,wrapped,cli,stats,recap
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
<div align="center">
|
|
21
|
+
|
|
22
|
+
# ◆ Claude Wrapped — Neo-Brutalist Edition
|
|
23
|
+
|
|
24
|
+
**Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
|
|
25
|
+
**Your Claude Code year, wrapped — neo-brutalist style.**
|
|
26
|
+
|
|
27
|
+
Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
|
|
28
|
+
partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
|
|
29
|
+
l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
|
|
30
|
+
|
|
31
|
+
`🇫🇷 Français` · `🇬🇧 English` · MIT
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
> **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
|
|
38
|
+
> par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
|
|
39
|
+
> transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
|
|
40
|
+
> néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
|
|
41
|
+
> *My Claude Wrapped*; same engine, different art direction.
|
|
42
|
+
|
|
43
|
+
## 🇫🇷 Français
|
|
44
|
+
|
|
45
|
+
### C'est quoi ?
|
|
46
|
+
|
|
47
|
+
Un générateur qui transforme ton historique **local** Claude Code
|
|
48
|
+
(`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
|
|
49
|
+
**néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
|
|
50
|
+
et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
|
|
51
|
+
|
|
52
|
+
### Installation & utilisation
|
|
53
|
+
|
|
54
|
+
Aucune dépendance — juste **Python 3.8+**.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install wrapped-claude
|
|
58
|
+
wrapped-claude # FR -> claude_wrapped.html (commande installée)
|
|
59
|
+
wrapped-claude --video --lang en # toutes les options, ex.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
66
|
+
cd claude-wrapped
|
|
67
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
68
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
69
|
+
python3 generate.py --days 30 # 30 derniers jours
|
|
70
|
+
python3 generate.py --images # exporte 1 PNG par écran -> images/
|
|
71
|
+
python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
|
|
72
|
+
python3 generate.py --video --video-music song.mp3 # + musique
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
|
|
76
|
+
> **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
|
|
77
|
+
> (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
|
|
78
|
+
> **Exécutable uniquement en CLI** (données locales).
|
|
79
|
+
|
|
80
|
+
Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
|
|
81
|
+
|
|
82
|
+
## 🇬🇧 English
|
|
83
|
+
|
|
84
|
+
### What is it?
|
|
85
|
+
|
|
86
|
+
A generator that turns your **local** Claude Code history
|
|
87
|
+
(`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
|
|
88
|
+
look. Nothing is sent anywhere: everything runs locally and the page contains
|
|
89
|
+
**only aggregate numbers** (never conversation content).
|
|
90
|
+
|
|
91
|
+
### Install & usage
|
|
92
|
+
|
|
93
|
+
No dependencies — just **Python 3.8+**.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install wrapped-claude
|
|
97
|
+
wrapped-claude # FR -> claude_wrapped.html (installed command)
|
|
98
|
+
wrapped-claude --video --lang en # all options, e.g.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
105
|
+
cd claude-wrapped
|
|
106
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
107
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
108
|
+
python3 generate.py --images # export one PNG per screen -> images/
|
|
109
|
+
python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
|
|
110
|
+
python3 generate.py --video --video-music song.mp3 # + music
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
|
|
114
|
+
> **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
|
|
115
|
+
> for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
|
|
116
|
+
> data). Then open the `.html` and scroll.
|
|
117
|
+
|
|
118
|
+
## Crédits / Credits
|
|
119
|
+
|
|
120
|
+
- Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
|
|
121
|
+
- Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
|
|
122
|
+
- Polices / fonts : Space Grotesk & Space Mono (OFL)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ◆ Claude Wrapped — Neo-Brutalist Edition
|
|
4
|
+
|
|
5
|
+
**Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
|
|
6
|
+
**Your Claude Code year, wrapped — neo-brutalist style.**
|
|
7
|
+
|
|
8
|
+
Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
|
|
9
|
+
partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
|
|
10
|
+
l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
|
|
11
|
+
|
|
12
|
+
`🇫🇷 Français` · `🇬🇧 English` · MIT
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
> **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
|
|
19
|
+
> par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
|
|
20
|
+
> transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
|
|
21
|
+
> néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
|
|
22
|
+
> *My Claude Wrapped*; same engine, different art direction.
|
|
23
|
+
|
|
24
|
+
## 🇫🇷 Français
|
|
25
|
+
|
|
26
|
+
### C'est quoi ?
|
|
27
|
+
|
|
28
|
+
Un générateur qui transforme ton historique **local** Claude Code
|
|
29
|
+
(`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
|
|
30
|
+
**néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
|
|
31
|
+
et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
|
|
32
|
+
|
|
33
|
+
### Installation & utilisation
|
|
34
|
+
|
|
35
|
+
Aucune dépendance — juste **Python 3.8+**.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install wrapped-claude
|
|
39
|
+
wrapped-claude # FR -> claude_wrapped.html (commande installée)
|
|
40
|
+
wrapped-claude --video --lang en # toutes les options, ex.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
47
|
+
cd claude-wrapped
|
|
48
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
49
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
50
|
+
python3 generate.py --days 30 # 30 derniers jours
|
|
51
|
+
python3 generate.py --images # exporte 1 PNG par écran -> images/
|
|
52
|
+
python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
|
|
53
|
+
python3 generate.py --video --video-music song.mp3 # + musique
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
|
|
57
|
+
> **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
|
|
58
|
+
> (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
|
|
59
|
+
> **Exécutable uniquement en CLI** (données locales).
|
|
60
|
+
|
|
61
|
+
Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
|
|
62
|
+
|
|
63
|
+
## 🇬🇧 English
|
|
64
|
+
|
|
65
|
+
### What is it?
|
|
66
|
+
|
|
67
|
+
A generator that turns your **local** Claude Code history
|
|
68
|
+
(`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
|
|
69
|
+
look. Nothing is sent anywhere: everything runs locally and the page contains
|
|
70
|
+
**only aggregate numbers** (never conversation content).
|
|
71
|
+
|
|
72
|
+
### Install & usage
|
|
73
|
+
|
|
74
|
+
No dependencies — just **Python 3.8+**.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install wrapped-claude
|
|
78
|
+
wrapped-claude # FR -> claude_wrapped.html (installed command)
|
|
79
|
+
wrapped-claude --video --lang en # all options, e.g.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
86
|
+
cd claude-wrapped
|
|
87
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
88
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
89
|
+
python3 generate.py --images # export one PNG per screen -> images/
|
|
90
|
+
python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
|
|
91
|
+
python3 generate.py --video --video-music song.mp3 # + music
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
|
|
95
|
+
> **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
|
|
96
|
+
> for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
|
|
97
|
+
> data). Then open the `.html` and scroll.
|
|
98
|
+
|
|
99
|
+
## Crédits / Credits
|
|
100
|
+
|
|
101
|
+
- Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
|
|
102
|
+
- Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
|
|
103
|
+
- Polices / fonts : Space Grotesk & Space Mono (OFL)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wrapped-claude"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Salem Gnandi" }]
|
|
13
|
+
keywords = ["claude", "claude-code", "wrapped", "cli", "stats", "recap"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Salemgnk/claude-wrapped"
|
|
24
|
+
Original = "https://github.com/HKafuiEPI/my_claude_wrapped"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
wrapped-claude = "wrapped_claude.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["wrapped_claude"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
wrapped_claude = ["template.html", "fonts/*.woff2"]
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
My Claude Wrapped — generate a shareable retro-arcade HTML page from your local
|
|
4
|
+
Claude Code transcripts (~/.claude/projects/**/*.jsonl).
|
|
5
|
+
|
|
6
|
+
My Claude Wrapped — génère une page HTML partageable (style arcade rétro) à
|
|
7
|
+
partir de tes transcripts locaux Claude Code.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 generate.py # full history, French (default)
|
|
11
|
+
python3 generate.py --lang en # English version
|
|
12
|
+
python3 generate.py --days 30 # last 30 days only
|
|
13
|
+
python3 generate.py --out wrapped.html # custom output name
|
|
14
|
+
python3 generate.py --projects-dir /path/to/projects
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import base64
|
|
20
|
+
import glob
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
32
|
+
WEEKDAY_ABBR = {
|
|
33
|
+
"fr": {"Monday": "LUN", "Tuesday": "MAR", "Wednesday": "MER", "Thursday": "JEU",
|
|
34
|
+
"Friday": "VEN", "Saturday": "SAM", "Sunday": "DIM"},
|
|
35
|
+
"en": {"Monday": "MON", "Tuesday": "TUE", "Wednesday": "WED", "Thursday": "THU",
|
|
36
|
+
"Friday": "FRI", "Saturday": "SAT", "Sunday": "SUN"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_ts(ts: str):
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pretty_project(raw: str) -> str:
|
|
48
|
+
"""'-home-pr1nx3-Frisz-frisz-flutter' -> 'Frisz/frisz/flutter'."""
|
|
49
|
+
s = raw
|
|
50
|
+
for prefix in ("-home-pr1nx3-", "-home-pr1nx3", "-Users-", "-home-"):
|
|
51
|
+
if s.startswith(prefix):
|
|
52
|
+
s = s[len(prefix):]
|
|
53
|
+
break
|
|
54
|
+
s = s.lstrip("-")
|
|
55
|
+
return s.replace("-", "/") or raw
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_real_prompt(msg: dict) -> bool:
|
|
59
|
+
"""A user message that is not just a tool_result / meta entry."""
|
|
60
|
+
c = msg.get("content")
|
|
61
|
+
if isinstance(c, str):
|
|
62
|
+
return bool(c.strip())
|
|
63
|
+
if isinstance(c, list):
|
|
64
|
+
for b in c:
|
|
65
|
+
if isinstance(b, str) and b.strip():
|
|
66
|
+
return True
|
|
67
|
+
if isinstance(b, dict) and b.get("type") == "text" and (b.get("text") or "").strip():
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def collect(projects_dir: str, since: datetime | None, lang: str):
|
|
73
|
+
files = glob.glob(os.path.join(projects_dir, "**", "*.jsonl"), recursive=True)
|
|
74
|
+
|
|
75
|
+
models = Counter()
|
|
76
|
+
tools = Counter()
|
|
77
|
+
hours = [0] * 24
|
|
78
|
+
weekdays = Counter()
|
|
79
|
+
by_project_prompts = Counter()
|
|
80
|
+
by_day = Counter()
|
|
81
|
+
sessions = set()
|
|
82
|
+
projects_seen = set()
|
|
83
|
+
|
|
84
|
+
tok_in = tok_out = cache_read = cache_create = 0
|
|
85
|
+
user_prompts = 0
|
|
86
|
+
assistant_msgs = 0
|
|
87
|
+
first_ts = last_ts = None
|
|
88
|
+
|
|
89
|
+
for f in files:
|
|
90
|
+
proj = f.split(os.sep + "projects" + os.sep)[-1].split(os.sep)[0]
|
|
91
|
+
try:
|
|
92
|
+
fh = open(f, encoding="utf-8", errors="ignore")
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
with fh:
|
|
96
|
+
for line in fh:
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if not line:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
o = json.loads(line)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
ts = o.get("timestamp")
|
|
106
|
+
dt = parse_ts(ts) if ts else None
|
|
107
|
+
if since and dt and dt < since:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
t = o.get("type")
|
|
111
|
+
if o.get("sessionId"):
|
|
112
|
+
sessions.add(o["sessionId"])
|
|
113
|
+
|
|
114
|
+
if dt:
|
|
115
|
+
if first_ts is None or dt < first_ts:
|
|
116
|
+
first_ts = dt
|
|
117
|
+
if last_ts is None or dt > last_ts:
|
|
118
|
+
last_ts = dt
|
|
119
|
+
hours[dt.hour] += 1
|
|
120
|
+
weekdays[dt.strftime("%A")] += 1
|
|
121
|
+
by_day[dt.date().isoformat()] += 1
|
|
122
|
+
|
|
123
|
+
if t == "user":
|
|
124
|
+
msg = o.get("message", {}) or {}
|
|
125
|
+
if is_real_prompt(msg):
|
|
126
|
+
user_prompts += 1
|
|
127
|
+
by_project_prompts[proj] += 1
|
|
128
|
+
projects_seen.add(proj)
|
|
129
|
+
elif t == "assistant":
|
|
130
|
+
projects_seen.add(proj)
|
|
131
|
+
msg = o.get("message", {}) or {}
|
|
132
|
+
m = msg.get("model")
|
|
133
|
+
if m and m != "<synthetic>":
|
|
134
|
+
models[m] += 1
|
|
135
|
+
assistant_msgs += 1
|
|
136
|
+
u = msg.get("usage", {}) or {}
|
|
137
|
+
tok_in += u.get("input_tokens", 0) or 0
|
|
138
|
+
tok_out += u.get("output_tokens", 0) or 0
|
|
139
|
+
cache_read += u.get("cache_read_input_tokens", 0) or 0
|
|
140
|
+
cache_create += u.get("cache_creation_input_tokens", 0) or 0
|
|
141
|
+
for b in (msg.get("content") or []):
|
|
142
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
143
|
+
tools[b.get("name", "?")] += 1
|
|
144
|
+
|
|
145
|
+
tokens_total = tok_in + tok_out + cache_read + cache_create
|
|
146
|
+
days_active = len(by_day)
|
|
147
|
+
busiest_day = by_day.most_common(1)[0] if by_day else ["-", 0]
|
|
148
|
+
peak_hour = max(range(24), key=lambda h: hours[h]) if any(hours) else 0
|
|
149
|
+
|
|
150
|
+
night = sum(hours[h] for h in list(range(22, 24)) + list(range(0, 5)))
|
|
151
|
+
total_h = sum(hours) or 1
|
|
152
|
+
night_pct = round(100 * night / total_h)
|
|
153
|
+
|
|
154
|
+
abbr = WEEKDAY_ABBR[lang]
|
|
155
|
+
weekday_list = [[abbr[d], weekdays.get(d, 0)] for d in WEEKDAYS]
|
|
156
|
+
busiest_weekday = max(WEEKDAYS, key=lambda d: weekdays.get(d, 0)) if weekdays else "Monday"
|
|
157
|
+
|
|
158
|
+
top_projects = [[pretty_project(p), n] for p, n in by_project_prompts.most_common(8)]
|
|
159
|
+
top_model = models.most_common(1)[0][0] if models else "-"
|
|
160
|
+
top_tool = tools.most_common(1)[0][0] if tools else "-"
|
|
161
|
+
|
|
162
|
+
persona = build_persona(top_model, top_tool, peak_hour, night_pct, lang)
|
|
163
|
+
achievements = build_achievements(
|
|
164
|
+
user_prompts, tokens_total, len(projects_seen), peak_hour,
|
|
165
|
+
top_tool, len(sessions), len(models), busiest_day[1], lang,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"meta": {
|
|
170
|
+
"lang": lang,
|
|
171
|
+
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
172
|
+
"start": first_ts.date().isoformat() if first_ts else "-",
|
|
173
|
+
"end": last_ts.date().isoformat() if last_ts else "-",
|
|
174
|
+
"days_active": days_active,
|
|
175
|
+
"player": os.environ.get("USER", "PLAYER 1"),
|
|
176
|
+
},
|
|
177
|
+
"totals": {
|
|
178
|
+
"sessions": len(sessions),
|
|
179
|
+
"projects": len(projects_seen),
|
|
180
|
+
"prompts": user_prompts,
|
|
181
|
+
"tokens_total": tokens_total,
|
|
182
|
+
"tokens_in": tok_in,
|
|
183
|
+
"tokens_out": tok_out,
|
|
184
|
+
"cache_read": cache_read,
|
|
185
|
+
"cache_create": cache_create,
|
|
186
|
+
"tool_calls": sum(tools.values()),
|
|
187
|
+
"assistant_msgs": assistant_msgs,
|
|
188
|
+
},
|
|
189
|
+
"models": models.most_common(8),
|
|
190
|
+
"tools": tools.most_common(6),
|
|
191
|
+
"hours": hours,
|
|
192
|
+
"weekdays": weekday_list,
|
|
193
|
+
"busiest_weekday": abbr[busiest_weekday],
|
|
194
|
+
"projects": top_projects,
|
|
195
|
+
"busiest_day": busiest_day,
|
|
196
|
+
"peak_hour": peak_hour,
|
|
197
|
+
"night_pct": night_pct,
|
|
198
|
+
"top_model": top_model,
|
|
199
|
+
"top_tool": top_tool,
|
|
200
|
+
"persona": persona,
|
|
201
|
+
"achievements": achievements,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_persona(top_model, top_tool, peak_hour, night_pct, lang):
|
|
206
|
+
model_short = top_model.replace("claude-", "").replace("-", " ").upper()
|
|
207
|
+
variants = {
|
|
208
|
+
"night": ("THE NIGHT CODER", "Tu codes quand le reste du monde dort.",
|
|
209
|
+
"You code while the rest of the world sleeps."),
|
|
210
|
+
"early": ("THE EARLY GRINDER", "Premier au clavier, café en main.",
|
|
211
|
+
"First to the keyboard, coffee in hand."),
|
|
212
|
+
"bash": ("THE TERMINAL GLADIATOR", "Le shell est ton arène.",
|
|
213
|
+
"The shell is your arena."),
|
|
214
|
+
"smith": ("THE CODE SMITH", "Tu forges du code, ligne par ligne.",
|
|
215
|
+
"You forge code, line by line."),
|
|
216
|
+
"builder":("THE BUILDER", "Tu construis, sans relâche.",
|
|
217
|
+
"You build, relentlessly."),
|
|
218
|
+
}
|
|
219
|
+
if night_pct >= 35:
|
|
220
|
+
key = "night"
|
|
221
|
+
elif 5 <= peak_hour <= 9:
|
|
222
|
+
key = "early"
|
|
223
|
+
elif top_tool == "Bash":
|
|
224
|
+
key = "bash"
|
|
225
|
+
elif top_tool in ("Edit", "Write"):
|
|
226
|
+
key = "smith"
|
|
227
|
+
else:
|
|
228
|
+
key = "builder"
|
|
229
|
+
title, sub_fr, sub_en = variants[key]
|
|
230
|
+
return {"title": title, "sub": sub_fr if lang == "fr" else sub_en, "main_model": model_short}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def build_achievements(prompts, tokens, projects, peak_hour, top_tool,
|
|
234
|
+
sessions, n_models, busy_day, lang):
|
|
235
|
+
fr = lang == "fr"
|
|
236
|
+
a = []
|
|
237
|
+
|
|
238
|
+
def add(icon, title_fr, title_en, desc_fr, desc_en):
|
|
239
|
+
a.append({"icon": icon,
|
|
240
|
+
"title": title_fr if fr else title_en,
|
|
241
|
+
"desc": desc_fr if fr else desc_en})
|
|
242
|
+
|
|
243
|
+
if prompts >= 500:
|
|
244
|
+
add("💬", "PROLIFIQUE", "PROLIFIC", f"{prompts} prompts envoyés", f"{prompts} prompts sent")
|
|
245
|
+
if tokens >= 1_000_000_000:
|
|
246
|
+
add("🪙", "TOKEN BILLIONAIRE", "TOKEN BILLIONAIRE",
|
|
247
|
+
f"{tokens/1e9:.1f}B tokens traités", f"{tokens/1e9:.1f}B tokens processed")
|
|
248
|
+
elif tokens >= 100_000_000:
|
|
249
|
+
add("🪙", "TOKEN TYCOON", "TOKEN TYCOON",
|
|
250
|
+
f"{tokens/1e6:.0f}M tokens traités", f"{tokens/1e6:.0f}M tokens processed")
|
|
251
|
+
if projects >= 20:
|
|
252
|
+
add("🗺️", "EXPLORATEUR", "EXPLORER", f"{projects} projets visités", f"{projects} projects visited")
|
|
253
|
+
if peak_hour >= 22 or peak_hour < 5:
|
|
254
|
+
add("🦉", "NOCTAMBULE", "NIGHT OWL", f"Pic d'activité à {peak_hour}h", f"Peak activity at {peak_hour}:00")
|
|
255
|
+
elif 5 <= peak_hour <= 9:
|
|
256
|
+
add("🌅", "LÈVE-TÔT", "EARLY BIRD", f"Pic d'activité à {peak_hour}h", f"Peak activity at {peak_hour}:00")
|
|
257
|
+
if top_tool == "Bash":
|
|
258
|
+
add("⚔️", "TERMINAL MASTER", "TERMINAL MASTER",
|
|
259
|
+
"Bash, ton arme de prédilection", "Bash, your weapon of choice")
|
|
260
|
+
if sessions >= 40:
|
|
261
|
+
add("🏃", "MARATHONIEN", "MARATHONER", f"{sessions} sessions jouées", f"{sessions} sessions played")
|
|
262
|
+
if n_models >= 5:
|
|
263
|
+
add("🎭", "COLLECTIONNEUR", "COLLECTOR", f"{n_models} modèles utilisés", f"{n_models} models used")
|
|
264
|
+
if busy_day >= 200:
|
|
265
|
+
add("🔥", "JOURNÉE DE FEU", "DAY ON FIRE", f"{busy_day} events en 1 jour", f"{busy_day} events in 1 day")
|
|
266
|
+
return a
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _font_faces() -> str:
|
|
270
|
+
"""@font-face en base64 (polices néo-brutalistes) pour un HTML 100% autonome."""
|
|
271
|
+
fonts_dir = Path(__file__).parent / "fonts"
|
|
272
|
+
faces = [("Grotesk", 700, "SpaceGrotesk-700.woff2"),
|
|
273
|
+
("Grotesk", 500, "SpaceGrotesk-500.woff2"),
|
|
274
|
+
("Mono", 700, "SpaceMono-700.woff2")]
|
|
275
|
+
css = ""
|
|
276
|
+
for family, weight, fn in faces:
|
|
277
|
+
p = fonts_dir / fn
|
|
278
|
+
if not p.exists():
|
|
279
|
+
continue
|
|
280
|
+
b64 = base64.b64encode(p.read_bytes()).decode()
|
|
281
|
+
css += (f"@font-face{{font-family:'{family}';font-weight:{weight};font-display:block;"
|
|
282
|
+
f"src:url(data:font/woff2;base64,{b64}) format('woff2');}}")
|
|
283
|
+
return css
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def render_html(data: dict, template_path: str) -> str:
|
|
287
|
+
tpl = Path(template_path).read_text(encoding="utf-8")
|
|
288
|
+
tpl = tpl.replace("/*__FONTS__*/", _font_faces())
|
|
289
|
+
return tpl.replace("/*__WRAPPED_DATA__*/", json.dumps(data, ensure_ascii=False))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- Optional PNG export (one image per screen) via a headless browser ---
|
|
293
|
+
# No Python dependency added: uses a system Chrome/Chromium if available.
|
|
294
|
+
|
|
295
|
+
CHROME_CANDIDATES = ["google-chrome-stable", "google-chrome", "chromium",
|
|
296
|
+
"chromium-browser", "chrome"]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def find_chrome() -> str | None:
|
|
300
|
+
for c in CHROME_CANDIDATES:
|
|
301
|
+
p = shutil.which(c)
|
|
302
|
+
if p:
|
|
303
|
+
return p
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _run(chrome: str, args: list, **kw):
|
|
308
|
+
return subprocess.run([chrome, "--headless", "--no-sandbox", "--disable-gpu",
|
|
309
|
+
"--hide-scrollbars"] + args,
|
|
310
|
+
capture_output=True, text=True, timeout=90, **kw)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def slide_count(chrome: str, url: str) -> int | None:
|
|
314
|
+
"""Read data-slides exposed by the page (falls back gracefully)."""
|
|
315
|
+
try:
|
|
316
|
+
out = _run(chrome, ["--virtual-time-budget=2500", "--dump-dom", url]).stdout
|
|
317
|
+
m = re.search(r'data-slides="(\d+)"', out)
|
|
318
|
+
if m:
|
|
319
|
+
return int(m.group(1))
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _render_screens(chrome: str, base: str, out_dir: str, width: int, height: int) -> int:
|
|
326
|
+
"""Capture chaque écran en PNG (mode ?shot). Retourne le nombre d'écrans."""
|
|
327
|
+
n = slide_count(chrome, base + "?shot=1") or 10
|
|
328
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
329
|
+
for i in range(1, n + 1):
|
|
330
|
+
_run(chrome, [
|
|
331
|
+
"--force-device-scale-factor=2",
|
|
332
|
+
f"--window-size={width},{height}",
|
|
333
|
+
"--virtual-time-budget=3000",
|
|
334
|
+
f"--screenshot={os.path.join(out_dir, f'slide_{i:02d}.png')}",
|
|
335
|
+
f"{base}?slide={i}&shot=1",
|
|
336
|
+
])
|
|
337
|
+
return n
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def render_images(html_path: str, out_dir: str, width: int, height: int) -> None:
|
|
341
|
+
chrome = find_chrome()
|
|
342
|
+
if not chrome:
|
|
343
|
+
print("⚠️ --images needs Chrome/Chromium (none found on PATH). Skipping image export.")
|
|
344
|
+
return
|
|
345
|
+
base = "file://" + os.path.abspath(html_path)
|
|
346
|
+
n = _render_screens(chrome, base, out_dir, width, height)
|
|
347
|
+
print(f"🖼️ {n} images ({width}x{height} @2x) → {out_dir}/")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def render_video(html_path: str, out_path: str, width: int, height: int,
|
|
351
|
+
dur: float, music: str | None) -> None:
|
|
352
|
+
"""Diaporama vidéo (fondus enchaînés) : écrans PNG (Chrome) assemblés par ffmpeg."""
|
|
353
|
+
chrome, ffmpeg = find_chrome(), shutil.which("ffmpeg")
|
|
354
|
+
if not chrome or not ffmpeg:
|
|
355
|
+
miss = " + ".join(m for m, ok in [("Chrome/Chromium", chrome), ("ffmpeg", ffmpeg)] if not ok)
|
|
356
|
+
print(f"⚠️ --video needs {miss} (not found on PATH). Skipping video export.")
|
|
357
|
+
return
|
|
358
|
+
base = "file://" + os.path.abspath(html_path)
|
|
359
|
+
tmp = tempfile.mkdtemp(prefix="claude-wrapped-video-")
|
|
360
|
+
try:
|
|
361
|
+
n = _render_screens(chrome, base, tmp, width, height)
|
|
362
|
+
T = 0.5 # durée du fondu enchaîné
|
|
363
|
+
inputs = []
|
|
364
|
+
for i in range(1, n + 1):
|
|
365
|
+
inputs += ["-loop", "1", "-framerate", "30", "-t", str(dur),
|
|
366
|
+
"-i", os.path.join(tmp, f"slide_{i:02d}.png")]
|
|
367
|
+
has_music = bool(music) and os.path.exists(music or "")
|
|
368
|
+
if has_music:
|
|
369
|
+
inputs += ["-i", music]
|
|
370
|
+
if n == 1:
|
|
371
|
+
filt = "[0]format=yuv420p[v]"
|
|
372
|
+
else:
|
|
373
|
+
parts, prev = [], "[0]"
|
|
374
|
+
for k in range(1, n):
|
|
375
|
+
out = "[vx]" if k == n - 1 else f"[v{k}]"
|
|
376
|
+
parts.append(f"{prev}[{k}]xfade=transition=fade:duration={T}:offset={k*(dur-T):.3f}{out}")
|
|
377
|
+
prev = out
|
|
378
|
+
filt = ";".join(parts) + ";[vx]format=yuv420p[v]"
|
|
379
|
+
cmd = [ffmpeg, "-y", *inputs, "-filter_complex", filt, "-map", "[v]"]
|
|
380
|
+
if has_music:
|
|
381
|
+
cmd += ["-map", f"{n}:a", "-shortest", "-c:a", "aac"]
|
|
382
|
+
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", out_path]
|
|
383
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
384
|
+
if r.returncode != 0:
|
|
385
|
+
print("⚠️ ffmpeg a échoué :", (r.stderr or "").strip().splitlines()[-1:] or "?")
|
|
386
|
+
return
|
|
387
|
+
print(f"🎬 vidéo ({n} écrans, ~{n * dur - (n - 1) * T:.0f}s"
|
|
388
|
+
f"{', musique' if has_music else ''}) → {out_path}")
|
|
389
|
+
finally:
|
|
390
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def main():
|
|
394
|
+
here = Path(__file__).parent
|
|
395
|
+
ap = argparse.ArgumentParser(description="Generate your Claude Code Wrapped.")
|
|
396
|
+
ap.add_argument("--projects-dir", default=os.path.expanduser("~/.claude/projects"))
|
|
397
|
+
ap.add_argument("--lang", choices=["fr", "en"], default="fr")
|
|
398
|
+
ap.add_argument("--days", type=int, default=None, help="Limit to the last N days")
|
|
399
|
+
ap.add_argument("--out", default=None)
|
|
400
|
+
ap.add_argument("--template", default=str(here / "template.html"))
|
|
401
|
+
ap.add_argument("--images", action="store_true",
|
|
402
|
+
help="Also export one PNG per screen (needs Chrome/Chromium)")
|
|
403
|
+
ap.add_argument("--images-dir", default=None,
|
|
404
|
+
help="Output folder for --images (default: <out folder>/images)")
|
|
405
|
+
ap.add_argument("--image-size", default="448x858",
|
|
406
|
+
help="WxH of exported images/video (default: 448x858, fills the phone frame)")
|
|
407
|
+
ap.add_argument("--video", action="store_true",
|
|
408
|
+
help="Also render a slideshow video (needs Chrome/Chromium + ffmpeg)")
|
|
409
|
+
ap.add_argument("--video-dur", type=float, default=3.0,
|
|
410
|
+
help="Seconds per screen in --video (default: 3)")
|
|
411
|
+
ap.add_argument("--video-music", default=None, help="Optional audio file for --video")
|
|
412
|
+
args = ap.parse_args()
|
|
413
|
+
|
|
414
|
+
out = args.out or ("claude_wrapped.html" if args.lang == "fr"
|
|
415
|
+
else "claude_wrapped.en.html")
|
|
416
|
+
|
|
417
|
+
since = None
|
|
418
|
+
if args.days:
|
|
419
|
+
since = datetime.now(timezone.utc) - timedelta(days=args.days)
|
|
420
|
+
|
|
421
|
+
print(f"⏳ Analyzing transcripts… (lang={args.lang})")
|
|
422
|
+
data = collect(args.projects_dir, since, args.lang)
|
|
423
|
+
html = render_html(data, args.template)
|
|
424
|
+
Path(out).write_text(html, encoding="utf-8")
|
|
425
|
+
|
|
426
|
+
t = data["totals"]
|
|
427
|
+
print("✅ Wrapped generated!")
|
|
428
|
+
print(f" → {out}")
|
|
429
|
+
print(f" Sessions: {t['sessions']} | Projects: {t['projects']} | Prompts: {t['prompts']}")
|
|
430
|
+
print(f" Tokens: {t['tokens_total']:,} | Top model: {data['top_model']} | Top tool: {data['top_tool']}")
|
|
431
|
+
print(f" Persona: {data['persona']['title']}")
|
|
432
|
+
|
|
433
|
+
if args.images or args.video:
|
|
434
|
+
try:
|
|
435
|
+
w, h = (int(x) for x in args.image_size.lower().split("x"))
|
|
436
|
+
except ValueError:
|
|
437
|
+
w, h = 448, 858
|
|
438
|
+
if args.images:
|
|
439
|
+
img_dir = args.images_dir or str(Path(out).resolve().parent / "images")
|
|
440
|
+
render_images(out, img_dir, w, h)
|
|
441
|
+
if args.video:
|
|
442
|
+
render_video(out, str(Path(out).with_suffix(".mp4")), w, h,
|
|
443
|
+
args.video_dur, args.video_music)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Claude Wrapped</title>
|
|
7
|
+
<style>
|
|
8
|
+
/*__FONTS__*/
|
|
9
|
+
:root{
|
|
10
|
+
--bg:#0C0C0E; --panel:#141417; --line:#2A2A31; --ink:#F4F3EE;
|
|
11
|
+
--dim:rgba(244,243,238,.52); --faint:rgba(244,243,238,.10);
|
|
12
|
+
--acc:#D6FF3D; --mono:"Mono",ui-monospace,Menlo,monospace;
|
|
13
|
+
--sans:"Grotesk","Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
14
|
+
}
|
|
15
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
16
|
+
html,body{height:100%}
|
|
17
|
+
body{background:#050506;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;
|
|
18
|
+
display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:100vh}
|
|
19
|
+
.stage{position:relative;width:min(100vw,448px);height:min(100dvh,858px);container-type:size;
|
|
20
|
+
overflow:hidden;background:var(--bg);border:2px solid var(--line);
|
|
21
|
+
box-shadow:0 40px 120px rgba(0,0,0,.7);user-select:none}
|
|
22
|
+
@media(max-width:500px){.stage{width:100vw;height:100dvh;border:none}}
|
|
23
|
+
.chrome{position:absolute;top:0;left:0;right:0;z-index:20;padding:2.6cqh 5.5cqw 0}
|
|
24
|
+
.sysbar{display:flex;align-items:center;justify-content:space-between;font-family:var(--mono);
|
|
25
|
+
font-size:1.9cqh;font-weight:700;letter-spacing:.06em;color:var(--dim);text-transform:uppercase}
|
|
26
|
+
.sysbar .dot{color:var(--acc)}
|
|
27
|
+
.prog{display:flex;gap:1.4cqw;margin-top:1.6cqh}
|
|
28
|
+
.prog i{flex:1;height:6px;border:1.5px solid var(--line);overflow:hidden}
|
|
29
|
+
.prog i b{display:block;height:100%;width:0;background:var(--acc)}
|
|
30
|
+
.ctrl{position:absolute;bottom:2.6cqh;right:5cqw;z-index:25;display:flex;gap:2cqw}
|
|
31
|
+
.cbtn{width:6.4cqh;height:6.4cqh;border:1.5px solid var(--line);background:rgba(10,9,8,.5);
|
|
32
|
+
color:var(--ink);font-size:2.6cqh;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
|
|
33
|
+
.deck{position:absolute;inset:0;z-index:5}
|
|
34
|
+
.card{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:flex-end;
|
|
35
|
+
gap:3cqh;padding:12cqh 6cqw 13cqh;overflow:hidden;opacity:0;pointer-events:none;transition:opacity .28s ease;
|
|
36
|
+
background:radial-gradient(82cqw 60cqh at 82% 4%,color-mix(in srgb,var(--acc) 22%,transparent),transparent 60%),
|
|
37
|
+
radial-gradient(60cqw 48cqh at 6% 102%,color-mix(in srgb,var(--acc) 9%,transparent),transparent 55%),var(--bg)}
|
|
38
|
+
.card.active{opacity:1;pointer-events:auto}
|
|
39
|
+
.card.top{justify-content:flex-start;padding:12cqh 6cqw 6cqh;gap:2.6cqh}
|
|
40
|
+
.card::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;opacity:.35;mix-blend-mode:soft-light;
|
|
41
|
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E")}
|
|
42
|
+
.card>*{position:relative;z-index:2}
|
|
43
|
+
.viz{position:absolute;top:15cqh;left:6cqw;right:6cqw;z-index:2}
|
|
44
|
+
.chart{display:flex;gap:.5cqw;height:10cqh;align-items:flex-end}
|
|
45
|
+
.chart i{flex:1;background:color-mix(in srgb,var(--acc) 72%,transparent);height:0;transition:height .9s cubic-bezier(.2,.8,.2,1)}
|
|
46
|
+
.icon{font-family:var(--mono);font-weight:700;font-size:7.5cqh;line-height:1;color:var(--acc)}
|
|
47
|
+
.eyebrow{font-family:var(--mono);font-size:2cqh;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--acc)}
|
|
48
|
+
.block{align-self:flex-start;border:2.5px solid var(--ink);box-shadow:8px 8px 0 var(--acc);background:var(--panel);padding:1cqh 3cqw}
|
|
49
|
+
.year{font-family:var(--sans);font-weight:700;line-height:.8;letter-spacing:-.04em;font-size:min(15cqh,20cqw);color:var(--ink);white-space:nowrap}
|
|
50
|
+
.num{font-family:var(--sans);font-weight:700;line-height:.82;letter-spacing:-.03em;font-size:min(15cqh,23cqw);color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
51
|
+
.unit{font-family:var(--mono);font-size:3cqh;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--acc)}
|
|
52
|
+
.joke{font-family:var(--mono);font-size:2.15cqh;line-height:1.4;color:rgba(244,243,238,.82);max-width:32ch}
|
|
53
|
+
.joke::before{content:"# ";color:var(--acc);font-weight:700}
|
|
54
|
+
.note{font-family:var(--mono);font-size:2cqh;line-height:1.4;color:var(--dim);max-width:34ch}
|
|
55
|
+
.title{font-family:var(--sans);font-weight:700;font-size:5.2cqh;letter-spacing:-.02em;line-height:1.02;max-width:15ch}
|
|
56
|
+
.title em{font-style:normal;color:var(--acc)}
|
|
57
|
+
.name{font-family:var(--sans);font-weight:700;line-height:.92;letter-spacing:-.02em;font-size:min(8.5cqh,12cqw);color:var(--acc);word-break:break-word}
|
|
58
|
+
.vtag{font-family:var(--sans);font-size:3.1cqh;font-weight:500;line-height:1.3;color:var(--ink);max-width:24ch;opacity:.92}
|
|
59
|
+
.badges{display:flex;flex-wrap:wrap;gap:1.6cqw}
|
|
60
|
+
.badge{font-family:var(--mono);font-size:1.9cqh;font-weight:700;letter-spacing:.02em;padding:.7cqh 2.4cqw;border:2px solid var(--acc);color:var(--acc)}
|
|
61
|
+
.rank{display:flex;flex-direction:column;gap:1.15cqh;font-family:var(--mono);font-size:2.35cqh;font-weight:700}
|
|
62
|
+
.rank .r{display:flex;align-items:baseline;gap:2.6cqw}
|
|
63
|
+
.rank .r .i{color:var(--dim)}
|
|
64
|
+
.rank .r.top{font-size:3.1cqh;margin-bottom:.6cqh}
|
|
65
|
+
.rank .r.top .i,.rank .r.top .n{color:var(--acc)}
|
|
66
|
+
.rank .r .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
67
|
+
.rank .r .c{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
68
|
+
.receipt{align-self:stretch;border:2.5px solid var(--ink);box-shadow:8px 8px 0 var(--acc);background:var(--panel);padding:2.4cqh 5cqw}
|
|
69
|
+
.receipt .rh{display:flex;justify-content:space-between;font-family:var(--mono);font-size:2cqh;font-weight:700;
|
|
70
|
+
letter-spacing:.1em;text-transform:uppercase;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:1.3cqh;margin-bottom:1.1cqh}
|
|
71
|
+
.receipt .row{display:flex;justify-content:space-between;gap:3cqw;font-family:var(--mono);font-size:2.3cqh;padding:.7cqh 0}
|
|
72
|
+
.receipt .row .k{color:var(--dim);text-transform:uppercase;letter-spacing:.06em;font-size:.82em;align-self:center}
|
|
73
|
+
.receipt .row .v{font-weight:700;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
74
|
+
.chip{align-self:flex-start;font-family:var(--mono);font-size:1.95cqh;font-weight:700;letter-spacing:.08em;
|
|
75
|
+
text-transform:uppercase;color:var(--ink);border:2px solid var(--line);padding:.7cqh 2.6cqw}
|
|
76
|
+
.chip .k{color:var(--acc)}
|
|
77
|
+
.cursor{display:inline-block;width:.09em;height:.74em;margin-left:.04em;background:var(--acc);vertical-align:baseline;animation:blink 1s steps(1) infinite}
|
|
78
|
+
@keyframes blink{50%{opacity:0}}
|
|
79
|
+
body.shot .chrome,body.shot .ctrl{display:none!important}
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<div class="stage" id="stage">
|
|
84
|
+
<div class="chrome"><div class="sysbar"><span><span class="dot">●</span> claude-wrapped</span>
|
|
85
|
+
<span id="counter">01 / 01</span></div><div class="prog" id="prog"></div></div>
|
|
86
|
+
<div class="deck" id="deck"></div>
|
|
87
|
+
<div class="ctrl"><button class="cbtn" id="prev">◄</button><button class="cbtn" id="next">►</button></div>
|
|
88
|
+
</div>
|
|
89
|
+
<script>
|
|
90
|
+
const DATA = /*__WRAPPED_DATA__*/;
|
|
91
|
+
const STRINGS = {
|
|
92
|
+
fr:{eyebrowIntro:"// ton année avec claude",titleA:"Tes ",titleB:"prompts",titleC:", wrappés.",
|
|
93
|
+
prompts:"prompts envoyés",promptsNote:(s,p,d)=>`sur ${s} sessions, ${p} projets, ${d} jours actifs.`,
|
|
94
|
+
tokensEye:"// tu as fait tourner",tokens:"tokens traités",tokensNote:(i,o,c)=>`${o} écrits par Claude · ${c} lus en cache`,
|
|
95
|
+
modelEye:"// ton modèle fétiche",toolEye:"// ton arme de prédilection",tool:"appels d'outils",
|
|
96
|
+
rhythmEye:"// ton rythme",rhythmTitleA:"Tu prompt surtout à ",rhythmTitleB:"h",
|
|
97
|
+
nightNote:(n)=>`${n}% de ton activité entre 22h et 5h.`,projEye:"// tes terrains de jeu",
|
|
98
|
+
verdictEye:"// verdict",recapEye:"// le récap",recap:"sessions · prompts · tokens",
|
|
99
|
+
wrap:"c'est ton année claude",genBy:"généré par claude-wrapped",chip:"▸ appuie pour démarrer"},
|
|
100
|
+
en:{eyebrowIntro:"// your year with claude",titleA:"Your ",titleB:"prompts",titleC:", wrapped.",
|
|
101
|
+
prompts:"prompts sent",promptsNote:(s,p,d)=>`across ${s} sessions, ${p} projects, ${d} active days.`,
|
|
102
|
+
tokensEye:"// you pushed",tokens:"tokens processed",tokensNote:(i,o,c)=>`${o} written by Claude · ${c} read from cache`,
|
|
103
|
+
modelEye:"// your main character",toolEye:"// your weapon of choice",tool:"tool calls",
|
|
104
|
+
rhythmEye:"// your rhythm",rhythmTitleA:"You mostly prompt at ",rhythmTitleB:":00",
|
|
105
|
+
nightNote:(n)=>`${n}% of your activity between 10pm and 5am.`,projEye:"// your playgrounds",
|
|
106
|
+
verdictEye:"// verdict",recapEye:"// the recap",recap:"sessions · prompts · tokens",
|
|
107
|
+
wrap:"that's your claude year",genBy:"generated by claude-wrapped",chip:"▸ tap to start"},
|
|
108
|
+
};
|
|
109
|
+
const S = STRINGS[(DATA.meta && DATA.meta.lang) || "fr"];
|
|
110
|
+
// --- punchlines sarcastiques ---
|
|
111
|
+
const JOKES = {
|
|
112
|
+
fr:{
|
|
113
|
+
prompts:n=> n<100?"Claude t'a à peine vu passer.":
|
|
114
|
+
n<500?"Le minimum syndical pour dire « j'utilise l'IA » en soirée.":
|
|
115
|
+
n<1500?"Tu as un problème. Un vrai. Et Claude n'est pas médecin.":
|
|
116
|
+
n<3000?"Tu ne codes plus, tu délègues ta vie. Et tu relis même pas.":
|
|
117
|
+
"À ce stade, Claude mérite un salaire, des congés, et une part de ton diplôme.",
|
|
118
|
+
tokens:n=> n>=1e9?"De quoi réécrire la bibliothèque d'Alexandrie. Toi, t'as pondu un CRUD.":
|
|
119
|
+
n>=1e8?"Des millions de tokens, et le bug est toujours là. Bravo l'équipe.":
|
|
120
|
+
"Radin en tokens. Ou alors t'abandonnes au premier « Réfléchissons ».",
|
|
121
|
+
tool:t=>({Bash:"rm -rf en confiance aveugle. Ton /home vit dangereusement.",
|
|
122
|
+
Edit:"Claude écrit, tu signes. Le CDI le plus tranquille de l'open space.",
|
|
123
|
+
Write:"Claude écrit, tu signes. Le CDI le plus tranquille de l'open space.",
|
|
124
|
+
Read:"Tu fais lire Claude et tu hoches la tête. Manager né."}[t]
|
|
125
|
+
|| (t+" non-stop. Ta seule vraie constance.")),
|
|
126
|
+
rhythm:(np,ph)=> np>=35?"3h du mat, toi et Claude, seuls contre le linter et le bon sens.":
|
|
127
|
+
ph<=9?"Lève-tôt. Tes prompts sentent le café et le désespoir matinal.":
|
|
128
|
+
"Actif en journée. Le strict minimum pour faire semblant.",
|
|
129
|
+
model:m=> (m||"Ton modèle")+", ton bras droit. Et ton bras gauche. Et ton cerveau.",
|
|
130
|
+
projects:"Autant de projets ouverts. Combien finis ? On ne posera pas la question.",
|
|
131
|
+
recap:"Ton année Claude, résumée. Ne montre pas ça à un recruteur.",
|
|
132
|
+
},
|
|
133
|
+
en:{
|
|
134
|
+
prompts:n=> n<100?"Claude barely noticed you.":
|
|
135
|
+
n<500?"The bare minimum to say 'I use AI' at parties.":
|
|
136
|
+
n<1500?"You have a problem. A real one. Claude isn't a doctor.":
|
|
137
|
+
n<3000?"You don't code anymore, you delegate your life. And you don't even read it.":
|
|
138
|
+
"At this point, Claude deserves a salary, PTO, and a cut of your degree.",
|
|
139
|
+
tokens:n=> n>=1e9?"Enough tokens to rewrite the Library of Alexandria. You shipped a CRUD.":
|
|
140
|
+
n>=1e8?"Millions of tokens, and the bug is still there. Great teamwork.":
|
|
141
|
+
"Stingy with tokens. Or you bail at the first 'Let's think'.",
|
|
142
|
+
tool:t=>({Bash:"rm -rf on blind faith. Your /home lives dangerously.",
|
|
143
|
+
Edit:"Claude writes, you sign. The comfiest job in the office.",
|
|
144
|
+
Write:"Claude writes, you sign. The comfiest job in the office.",
|
|
145
|
+
Read:"You make Claude read and you nod along. Born manager."}[t]
|
|
146
|
+
|| (t+" non-stop. Your only real consistency.")),
|
|
147
|
+
rhythm:(np,ph)=> np>=35?"3am, you and Claude, alone against the linter and common sense.":
|
|
148
|
+
ph<=9?"Early bird. Your prompts smell of coffee and morning despair.":
|
|
149
|
+
"Active by day. The bare minimum to look busy.",
|
|
150
|
+
model:m=> (m||"Your model")+", your right hand. And your left. And your brain.",
|
|
151
|
+
projects:"So many open projects. How many finished? We won't ask.",
|
|
152
|
+
recap:"Your Claude year, wrapped. Don't show this to a recruiter.",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const J = JOKES[(DATA.meta && DATA.meta.lang) || "fr"];
|
|
156
|
+
const ACCENTS = ["#D6FF3D","#FF5CA8","#4DE1FF","#FF7A3C","#9B7CFF","#4CE6A0"];
|
|
157
|
+
const T = DATA.totals || {}, M = DATA.meta || {};
|
|
158
|
+
|
|
159
|
+
function fmt(n){ n=+n||0;
|
|
160
|
+
if(n>=1e9) return (n/1e9).toFixed(n<1e10?1:0)+"B";
|
|
161
|
+
if(n>=1e6) return (n/1e6).toFixed(n<1e7?1:0)+"M";
|
|
162
|
+
if(n>=1e3) return (n/1e3).toFixed(n<1e4?1:0)+"k";
|
|
163
|
+
return String(n); }
|
|
164
|
+
function fmtFull(n){ return (+n||0).toLocaleString("fr-FR"); }
|
|
165
|
+
function el(t,c,h){ const e=document.createElement(t); if(c)e.className=c; if(h!=null)e.innerHTML=h; return e; }
|
|
166
|
+
function shortModel(m){ return (m||"-").replace("claude-","").replace(/-/g," ").toUpperCase(); }
|
|
167
|
+
|
|
168
|
+
const deck=document.getElementById("deck"), slides=[];
|
|
169
|
+
function card(ai){ const c=el("div","card"); c.style.setProperty("--acc",ACCENTS[ai%ACCENTS.length]); return c; }
|
|
170
|
+
function add(builder){ const c=builder(); deck.appendChild(c); slides.push(c); }
|
|
171
|
+
function chart(vals){ const max=Math.max(1,...vals); const v=el("div","viz"),ch=el("div","chart");
|
|
172
|
+
vals.forEach(val=>{ const b=el("i"); b.dataset.h=Math.max(3,Math.round(val/max*100)); ch.appendChild(b); });
|
|
173
|
+
v.appendChild(ch); return v; }
|
|
174
|
+
function rankList(items){ const box=el("div","rank");
|
|
175
|
+
items.slice(0,5).forEach((it,i)=>{ const r=el("div","r"+(i===0?" top":""));
|
|
176
|
+
r.appendChild(el("span","i",String(i+1).padStart(2,"0")));
|
|
177
|
+
r.appendChild(el("span","n",it[0])); r.appendChild(el("span","c",fmtFull(it[1]))); box.appendChild(r); });
|
|
178
|
+
return box; }
|
|
179
|
+
|
|
180
|
+
// --- slides ---
|
|
181
|
+
add(()=>{ const c=card(0); c.appendChild(el("div","eyebrow",S.eyebrowIntro));
|
|
182
|
+
const b=el("div","block"); const y=el("div","year","CLAUDE"); b.appendChild(y); c.appendChild(b);
|
|
183
|
+
c.appendChild(el("div","title",S.titleA+"<em>WRAPPED</em>"));
|
|
184
|
+
c.appendChild(el("div","note",`${(M.player||"PLAYER").toUpperCase()} · ${M.start||"?"} → ${M.end||"?"}`));
|
|
185
|
+
c.appendChild(el("div","chip",`<span class="k">▸</span> ${S.chip.replace("▸ ","")}`)); return c; });
|
|
186
|
+
|
|
187
|
+
add(()=>{ const c=card(1); c.appendChild(el("div","eyebrow","// tu as envoyé"));
|
|
188
|
+
const b=el("div","block"); const n=el("div","num",fmtFull(T.prompts||0)); b.appendChild(n); c.appendChild(b);
|
|
189
|
+
c.appendChild(el("div","unit",S.prompts));
|
|
190
|
+
c.appendChild(el("div","joke",J.prompts(T.prompts||0)));
|
|
191
|
+
c.appendChild(el("div","note",S.promptsNote(fmtFull(T.sessions||0),fmtFull(T.projects||0),fmtFull(M.days_active||0)))); return c; });
|
|
192
|
+
|
|
193
|
+
add(()=>{ const c=card(2); c.appendChild(el("div","eyebrow",S.tokensEye));
|
|
194
|
+
const b=el("div","block"); b.appendChild(el("div","num",fmt(T.tokens_total||0))); c.appendChild(b);
|
|
195
|
+
c.appendChild(el("div","unit",S.tokens));
|
|
196
|
+
c.appendChild(el("div","joke",J.tokens(T.tokens_total||0)));
|
|
197
|
+
c.appendChild(el("div","note",S.tokensNote(fmt(T.tokens_in),fmt(T.tokens_out),fmt(T.cache_read)))); return c; });
|
|
198
|
+
|
|
199
|
+
add(()=>{ const c=card(3); c.classList.add("top"); c.appendChild(el("div","icon","✦"));
|
|
200
|
+
c.appendChild(el("div","eyebrow",S.modelEye));
|
|
201
|
+
c.appendChild(el("div","name",shortModel(DATA.top_model)));
|
|
202
|
+
c.appendChild(rankList((DATA.models||[]).map(m=>[shortModel(m[0]),m[1]])));
|
|
203
|
+
c.appendChild(el("div","joke",J.model(shortModel(DATA.top_model)))); return c; });
|
|
204
|
+
|
|
205
|
+
add(()=>{ const c=card(4); c.classList.add("top"); c.appendChild(el("div","icon","⚔"));
|
|
206
|
+
c.appendChild(el("div","eyebrow",S.toolEye));
|
|
207
|
+
c.appendChild(el("div","name",DATA.top_tool||"-"));
|
|
208
|
+
c.appendChild(rankList((DATA.tools||[]).map(t=>[t[0],t[1]])));
|
|
209
|
+
c.appendChild(el("div","joke",J.tool(DATA.top_tool||""))); return c; });
|
|
210
|
+
|
|
211
|
+
add(()=>{ const c=card(5); c.appendChild(chart(DATA.hours||new Array(24).fill(0)));
|
|
212
|
+
c.appendChild(el("div","eyebrow",S.rhythmEye));
|
|
213
|
+
const t=el("div","title"); t.innerHTML=S.rhythmTitleA+`<em>${DATA.peak_hour||0}${S.rhythmTitleB}</em>.`;
|
|
214
|
+
c.appendChild(t); c.appendChild(el("div","joke",J.rhythm(DATA.night_pct||0,DATA.peak_hour||0)));
|
|
215
|
+
c.appendChild(el("div","note",S.nightNote(DATA.night_pct||0))); return c; });
|
|
216
|
+
|
|
217
|
+
if((DATA.projects||[]).length){ add(()=>{ const c=card(2); c.classList.add("top");
|
|
218
|
+
c.appendChild(el("div","icon","▚")); c.appendChild(el("div","eyebrow",S.projEye));
|
|
219
|
+
c.appendChild(rankList(DATA.projects));
|
|
220
|
+
c.appendChild(el("div","joke",J.projects)); return c; }); }
|
|
221
|
+
|
|
222
|
+
add(()=>{ const c=card(4); c.classList.add("top"); c.appendChild(el("div","icon","❖"));
|
|
223
|
+
c.appendChild(el("div","eyebrow",S.verdictEye));
|
|
224
|
+
c.appendChild(el("div","name",(DATA.persona&&DATA.persona.title)||"-"));
|
|
225
|
+
c.appendChild(el("div","vtag","« "+((DATA.persona&&DATA.persona.sub)||"")+" »"));
|
|
226
|
+
if((DATA.achievements||[]).length){ const b=el("div","badges");
|
|
227
|
+
DATA.achievements.slice(0,6).forEach(a=>b.appendChild(el("span","badge",(a.icon||"")+" "+a.title)));
|
|
228
|
+
c.appendChild(b); } return c; });
|
|
229
|
+
|
|
230
|
+
add(()=>{ const c=card(3); c.appendChild(el("div","eyebrow",S.recapEye));
|
|
231
|
+
const r=el("div","receipt"); const h=el("div","rh");
|
|
232
|
+
h.appendChild(el("span",null,(M.player||"claude").toUpperCase())); h.appendChild(el("span",null,(M.end||"").slice(0,4)));
|
|
233
|
+
r.appendChild(h);
|
|
234
|
+
[["sessions",fmtFull(T.sessions||0)],["prompts",fmtFull(T.prompts||0)],["tokens",fmt(T.tokens_total||0)],
|
|
235
|
+
["modèle",shortModel(DATA.top_model)],["outil",DATA.top_tool||"-"],["profil",(DATA.persona&&DATA.persona.title)||"-"]]
|
|
236
|
+
.forEach(([k,v])=>{ const row=el("div","row"); row.appendChild(el("span","k",k)); row.appendChild(el("span","v",v)); r.appendChild(row); });
|
|
237
|
+
c.appendChild(r); c.appendChild(el("div","unit",S.wrap));
|
|
238
|
+
c.appendChild(el("div","joke",J.recap));
|
|
239
|
+
c.appendChild(el("div","note",S.genBy)); return c; });
|
|
240
|
+
|
|
241
|
+
// --- navigation ---
|
|
242
|
+
const prog=document.getElementById("prog"), counter=document.getElementById("counter");
|
|
243
|
+
slides.forEach(()=>{ const i=el("i"); i.appendChild(el("b")); prog.appendChild(i); });
|
|
244
|
+
const dots=[...prog.children], pad=n=>String(n).padStart(2,"0");
|
|
245
|
+
let idx=0;
|
|
246
|
+
function show(i){ i=Math.max(0,Math.min(slides.length-1,i)); idx=i;
|
|
247
|
+
slides.forEach((s,j)=>s.classList.toggle("active",j===i));
|
|
248
|
+
counter.textContent=pad(i+1)+" / "+pad(slides.length);
|
|
249
|
+
slides[i].querySelectorAll("[data-h]").forEach(b=>b.style.height=b.dataset.h+"%");
|
|
250
|
+
dots.forEach((d,j)=>{ d.firstChild.style.width=j<=i?"100%":"0%"; }); }
|
|
251
|
+
document.getElementById("next").onclick=e=>{e.stopPropagation();show(idx+1);};
|
|
252
|
+
document.getElementById("prev").onclick=e=>{e.stopPropagation();show(idx-1);};
|
|
253
|
+
document.getElementById("stage").addEventListener("click",e=>{ if(e.target.closest("button"))return;
|
|
254
|
+
const r=e.currentTarget.getBoundingClientRect(); show(idx+((e.clientX-r.left)<r.width*0.32?-1:1)); });
|
|
255
|
+
addEventListener("keydown",e=>{ if(e.key==="ArrowRight"||e.key===" "){show(idx+1);e.preventDefault();}
|
|
256
|
+
else if(e.key==="ArrowLeft")show(idx-1); });
|
|
257
|
+
let sx=null; const st=document.getElementById("stage");
|
|
258
|
+
st.addEventListener("touchstart",e=>sx=e.touches[0].clientX,{passive:true});
|
|
259
|
+
st.addEventListener("touchend",e=>{ if(sx==null)return; const dx=e.changedTouches[0].clientX-sx;
|
|
260
|
+
if(dx<-42)show(idx+1); else if(dx>42)show(idx-1); sx=null; });
|
|
261
|
+
show(0);
|
|
262
|
+
|
|
263
|
+
// mode capture (generate.py --images) : ?shot=1 masque le chrome, ?slide=N cible un écran
|
|
264
|
+
document.documentElement.dataset.slides = slides.length;
|
|
265
|
+
{ const q=new URLSearchParams(location.search);
|
|
266
|
+
if(q.get("shot")){ document.body.classList.add("shot");
|
|
267
|
+
show(Math.max(1,Math.min(slides.length,+(q.get("slide")||1)))-1); } }
|
|
268
|
+
</script>
|
|
269
|
+
</body>
|
|
270
|
+
</html>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wrapped-claude
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI).
|
|
5
|
+
Author: Salem Gnandi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Salemgnk/claude-wrapped
|
|
8
|
+
Project-URL: Original, https://github.com/HKafuiEPI/my_claude_wrapped
|
|
9
|
+
Keywords: claude,claude-code,wrapped,cli,stats,recap
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
<div align="center">
|
|
21
|
+
|
|
22
|
+
# ◆ Claude Wrapped — Neo-Brutalist Edition
|
|
23
|
+
|
|
24
|
+
**Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
|
|
25
|
+
**Your Claude Code year, wrapped — neo-brutalist style.**
|
|
26
|
+
|
|
27
|
+
Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
|
|
28
|
+
partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
|
|
29
|
+
l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
|
|
30
|
+
|
|
31
|
+
`🇫🇷 Français` · `🇬🇧 English` · MIT
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
> **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
|
|
38
|
+
> par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
|
|
39
|
+
> transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
|
|
40
|
+
> néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
|
|
41
|
+
> *My Claude Wrapped*; same engine, different art direction.
|
|
42
|
+
|
|
43
|
+
## 🇫🇷 Français
|
|
44
|
+
|
|
45
|
+
### C'est quoi ?
|
|
46
|
+
|
|
47
|
+
Un générateur qui transforme ton historique **local** Claude Code
|
|
48
|
+
(`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
|
|
49
|
+
**néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
|
|
50
|
+
et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
|
|
51
|
+
|
|
52
|
+
### Installation & utilisation
|
|
53
|
+
|
|
54
|
+
Aucune dépendance — juste **Python 3.8+**.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install wrapped-claude
|
|
58
|
+
wrapped-claude # FR -> claude_wrapped.html (commande installée)
|
|
59
|
+
wrapped-claude --video --lang en # toutes les options, ex.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
66
|
+
cd claude-wrapped
|
|
67
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
68
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
69
|
+
python3 generate.py --days 30 # 30 derniers jours
|
|
70
|
+
python3 generate.py --images # exporte 1 PNG par écran -> images/
|
|
71
|
+
python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
|
|
72
|
+
python3 generate.py --video --video-music song.mp3 # + musique
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
|
|
76
|
+
> **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
|
|
77
|
+
> (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
|
|
78
|
+
> **Exécutable uniquement en CLI** (données locales).
|
|
79
|
+
|
|
80
|
+
Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
|
|
81
|
+
|
|
82
|
+
## 🇬🇧 English
|
|
83
|
+
|
|
84
|
+
### What is it?
|
|
85
|
+
|
|
86
|
+
A generator that turns your **local** Claude Code history
|
|
87
|
+
(`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
|
|
88
|
+
look. Nothing is sent anywhere: everything runs locally and the page contains
|
|
89
|
+
**only aggregate numbers** (never conversation content).
|
|
90
|
+
|
|
91
|
+
### Install & usage
|
|
92
|
+
|
|
93
|
+
No dependencies — just **Python 3.8+**.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install wrapped-claude
|
|
97
|
+
wrapped-claude # FR -> claude_wrapped.html (installed command)
|
|
98
|
+
wrapped-claude --video --lang en # all options, e.g.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone git@github.com:Salemgnk/claude-wrapped.git
|
|
105
|
+
cd claude-wrapped
|
|
106
|
+
python3 generate.py # FR -> claude_wrapped.html
|
|
107
|
+
python3 generate.py --lang en # EN -> claude_wrapped.en.html
|
|
108
|
+
python3 generate.py --images # export one PNG per screen -> images/
|
|
109
|
+
python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
|
|
110
|
+
python3 generate.py --video --video-music song.mp3 # + music
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
|
|
114
|
+
> **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
|
|
115
|
+
> for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
|
|
116
|
+
> data). Then open the `.html` and scroll.
|
|
117
|
+
|
|
118
|
+
## Crédits / Credits
|
|
119
|
+
|
|
120
|
+
- Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
|
|
121
|
+
- Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
|
|
122
|
+
- Polices / fonts : Space Grotesk & Space Mono (OFL)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
wrapped_claude/__init__.py
|
|
5
|
+
wrapped_claude/cli.py
|
|
6
|
+
wrapped_claude/template.html
|
|
7
|
+
wrapped_claude.egg-info/PKG-INFO
|
|
8
|
+
wrapped_claude.egg-info/SOURCES.txt
|
|
9
|
+
wrapped_claude.egg-info/dependency_links.txt
|
|
10
|
+
wrapped_claude.egg-info/entry_points.txt
|
|
11
|
+
wrapped_claude.egg-info/top_level.txt
|
|
12
|
+
wrapped_claude/fonts/SpaceGrotesk-500.woff2
|
|
13
|
+
wrapped_claude/fonts/SpaceGrotesk-700.woff2
|
|
14
|
+
wrapped_claude/fonts/SpaceMono-700.woff2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wrapped_claude
|