SLOPSHOPPER

stats-board

Panneau de statistiques Claude Code (/ccstats) et bandeau au-dessus du prompt : discussion ouverte (coût, contexte, rythme horaire), récap de la journée, coût…

panebandcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stats-board
│ ┃ Stats Claude Code ✕ › fix the failing auth test and add an audit log call │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ ◆ Stats Claude Code │ ⎿ Read 6 lines │ ┃ │ Aucun relevé pour l’instant. Le │ ⏺ Update(src/auth.ts) │ ┃ │ relevé lance « npx ccusage@latest » │ ⎿ Added 2 lines, removed 1 line │ ┃ │ (coût, tokens, sessions, blocs de 5 │ ⏺ Bash(bun test) │ ┃ │ h), lit le cache de /stats et ajoute │ ⎿ 3 pass, 1 fail │ ┃ │ les usages des autres postes. │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ [ ▶ Lancer le relevé ] │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰────────────────────────────────────────╯ │ ┃ › /ccstats │ ⎿ stats-board: Panneau Stats ouvert. │ │ ▍ Stats 💬 0,42 $ 98,9 k ▸ /ccstats x ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▍ Stats 💬 0,42 $ 98,9 k ▸ /ccstats x
Pane · Stats Claude Code
╭────────────────────────────────────────────────────────╮ │ │ │ ◆ Stats Claude Code │ │ Aucun relevé pour l’instant. Le relevé lance « npx │ │ ccusage@latest » (coût, tokens, sessions, blocs de 5 │ │ h), lit le cache de /stats et ajoute les usages des │ │ autres postes. │ │ │ │ [ ▶ Lancer le relevé ] │ │ │ ╰────────────────────────────────────────────────────────╯
README

stats-board

Panneau de statistiques Claude Code et bandeau au-dessus du prompt, sur le terminal comme sur le desktop. Il montre la discussion ouverte (coût, contexte, rythme horaire et son feu), le récap de la journée, le coût équivalent API, les tokens, les modèles, les projets, les sessions, les blocs de 5 h, un calendrier d'activité et le rythme de travail.

  • Les prix viennent de ccusage. Le relevé lance npx ccusage@latest (rapports claude daily, session et blocks, en JSON). Les prix sont ceux du barème en ligne, ou les prix embarqués de ccusage si le réseau manque.
  • Tous les postes, dans un même panneau. Chaque poste publie ses usages dans un dépôt git privé à vous (par exemple claude-usage), cloné dans ~/.claude/claude-usage, et lit ceux des autres : Portable (le PC Windows), Mac et Serveur (l'ancien PC, figé). Un même projet sur deux postes est reconnu à son nom de dossier : C:\dev\PolePredict et ~/dev/PolePredict ne font qu'un. Voir « Partage entre postes ».
  • Messages, outils, heures et blocs de 5 h ne concernent que ce poste. Ils viennent du cache de /stats (~/.claude/stats-cache.json) et de ccusage, qui ne sont pas publiés.
  • Ce qu'il écrit : son propre stockage ($.store) et, dans ~/.claude/claude-usage, le seul fichier de ce poste (machines/<nom>.json), qu'il commite et pousse.

Version actuelle : 0.9.0. Historique des versions : CHANGELOG.md.


Sommaire

  1. Installation
  2. Utilisation
  3. Le panneau
  4. Le bandeau
  5. Commandes
  6. Données et relevés
  7. Architecture
  8. Développement
  9. Limites connues

Installation

Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/stats-board/). Installation d'un poste et protocole de développement : README à la racine du dépôt.

claude plugin marketplace update mathis-plugins
claude plugin install stats-board@mathis-plugins

Pour une nouvelle version : modifier la source dans le clone de travail (plugins/stats-board), monter version dans .claude-plugin/plugin.json et dans l'entrée du mod de .claude-plugin/marketplace.json à la racine du dépôt, commiter et pousser, puis :

claude plugin marketplace update mathis-plugins
claude plugin update stats-board@mathis-plugins

Une mise à jour s'applique aux discussions démarrées après elle, ou après /reload-plugins.

Prérequis

  • Claude Code avec les function hooks : mods de la version 2.1.286 ou plus récente.
  • Node.js (node et npx dans le PATH) : ccusage s'installe à la volée par npx.
  • Le réseau pour le premier relevé (téléchargement de ccusage) et pour les prix en ligne.
  • Pour le partage entre postes : git, un dépôt git privé à vous, accessible sans invite (gh auth setup-git), cloné dans ~/.claude/claude-usage, avec le nom du poste. /setup:install s'en charge. Sans eux, le mod marche pour ce poste seul et le dit dans l'en-tête.

Utilisation

  • Le bandeau apparaît au-dessus du prompt dès le démarrage de la session. « ▸ /ccstats » ouvre le panneau.
  • /stats-board:ccstats (ou /ccstats) ouvre le panneau.
  • « ↻ Actualiser » dans le panneau relance ccusage.

Le panneau ne s'ouvre jamais seul : seulement par une commande ou par « ▸ /ccstats » dans le bandeau.

  • /stats-board:recap (ou une demande comme « combien m'a coûté cette discussion », « récap de la journée », « récap du parcours ») fait le récap dans le chat : voir Le skill recap.

Le panneau

De haut en bas :

  1. La carte d'en-tête.
  2. Ligne 1 : la pastille « ◆ Stats Claude Code », l'âge du relevé et « ↻ Actualiser ».
  3. Ligne 2 : la période (Tout, 90 j, 30 j, 7 j), le poste (Tous les postes, puis chaque poste, celui-ci d'abord ; absent avec un seul poste) et « ⌂ Ce projet », qui filtre sur le dossier de la session.
  4. Les filtres de projet ou de modèle posés depuis les onglets, chacun avec son « ✕ ».
  5. La couverture : premier et dernier jour, nombre de jours, version de ccusage, « prix hors ligne » le cas échéant.
  6. Les avertissements : modèles sans prix chez ccusage (comptés 0 $), jours gardés par le mod, partage entre postes en échec ou désactivé, fichier d'un autre poste illisible.
  7. La zone d'activité : le relevé en cours avec ses secondes, puis le dernier message. Une erreur reste jusqu'à son « x ».
  8. Les onglets.
  9. Discussion : la discussion ouverte. Les filtres de l'en-tête ne s'y appliquent pas.
  10. En direct, d'après le moteur : son coût (le même que /cost), le remplissage du contexte, ses tours, son modèle, sa durée.
  11. Le rythme : la dépense de la dernière heure d'appels (ou, à défaut, la moyenne depuis le début), avec sa flamme, sa jauge et les cinq niveaux.
  12. Au « ↻ Discussion » (ou à la première ouverture par /ccstats discussion), le détail de ccusage sur cette seule discussion : tokens par type, frise du coût ou des tokens par tranche de 5 min à 2 j, empilée par modèle avec le cumul, modèles, appels les plus chers, sous-agents de workflow qu'elle a lancés.
  13. ccusage ne donne que le total : le coût de chaque appel est estimé, en répartissant ce total selon les tokens de l'appel et le barème relatif d'Anthropic (entrée 1, cache écrit 1,25, cache lu 0,1, sortie 5), au prix moyen de son modèle sur tout le relevé.
  14. Les tokens affichés sont ceux du détail (ou de la session dans le relevé), plus ceux des tours finis depuis, d'après le moteur.
  15. Journée : le récap d'une journée. Par défaut le jour du relevé, comme le bandeau (aujourd'hui, ou le jour d'un relevé plus ancien, signalé avec « ↻ Actualiser »). Les filtres de poste, de projet et de modèle s'y appliquent ; la période, non.
  16. En tête, le choix du jour : « ◂ veille » et « lendemain ▸ », le jour actif d'avant ou d'après quand ce n'est pas la veille ou le lendemain (« ◂◂ 8 oct. », « 12 oct. ▸▸ », sous les filtres), et « ↺ aujourd'hui » pour revenir au jour du relevé. Rien avant le premier jour connu ni après le relevé. /ccstats day revient au jour du relevé.
  17. La carte du coût du jour : son écart avec la veille et la courbe des 30 jours qui finissent ce jour-là.
  18. Des tuiles : tokens, part lue en cache, la veille, la moyenne par jour actif sur 30 jours et l'écart, le rang du jour parmi les jours actifs des 30 derniers, les discussions et leurs sous-agents, le modèle principal, et les messages de ce poste quand le cache de /stats compte déjà ce jour-là (il a en général un jour de retard).
  19. Les projets du jour (part, jauge, discussions, part des autres postes), la part de chaque poste quand plusieurs ont travaillé ce jour-là (jusqu'à leur dernière publication), les discussions du jour, les modèles du jour, puis les blocs de 5 h du jour (ce poste) avec leur rythme. Projets et discussions s'arrêtent à 8, le reste additionné sur une ligne.
  20. Une discussion du jour est commencée ou terminée ce jour-là (ccusage ne donne que la première et la dernière activité) ; elle compte avec tous ses sous-agents de workflow. Son coût est son total, jours précédents compris : ccusage ne le découpe pas par jour.
  21. Le rythme d'un bloc fini est sa dépense par heure active, comme les seuils du feu ; celui du bloc en cours vient de ccusage.
  22. Aperçu : la carte du coût équivalent API, en grand, avec sa variation par rapport à la période d'avant et la courbe de la période. Puis des tuiles : tokens, part lue en cache, jours actifs et séries, modèle favori, jour record, sessions, messages, heure de pointe, plus longue session. Ensuite le calendrier d'activité (en coût ou en tokens, niveaux par quartiles comme GitHub) et une comparaison avec un livre.
  23. Évolution : coût ou tokens par jour, semaine ou mois, en barres empilées par modèle avec la courbe du cumul. Suivent le mois en cours (dépensé, projection à la fin du mois, mois d'avant à la même date), le bloc de 5 h en cours (temps écoulé, coût, rythme, projection) et les jours les plus chargés.
  24. Modèles : l'anneau de répartition, la part de chaque modèle dans le temps, puis le détail par modèle (entrée, sortie, cache lu et écrit, coût, mini-courbe), avec « ⌕ filtrer ».
  25. Projets : les projets classés par coût ou tokens, avec part, jauge, sessions, dernier jour et dossier, et « ⌕ filtrer ». Les brouillons du desktop (scratch-workspaces) sont réunis en un seul projet. Un worktree de Claude (<dépôt>/.claude/worktrees/<nom>) compte pour le projet de son dépôt. Un projet travaillé sur plusieurs postes les additionne et dit la part des autres (« dont Mac …, Serveur … »).
  26. Sessions : nombre, coût moyen, durée médiane, plus longue, puis la liste triée par coût, date ou durée ; avec plusieurs postes, chaque session dit le sien. Le Serveur n'a publié que des totaux : pas de sessions. Ensuite les derniers blocs de 5 h (ce poste).
  27. Rythme : les heures de début des sessions, la charge par jour de la semaine, les messages et appels d'outils, la régularité (jours actifs, série record, série en cours).

Les listes longues sont plafonnées (6 modèles, 8 projets, 10 sessions, 5 jours) avec « ▸ tout voir ».

Sur le desktop, VS Code et le mobile, les graphiques sont des SVG. Sur le terminal, le calendrier et les colonnes (courbes des cartes de coût, barres de l'Évolution et de la discussion, blocs de 5 h, heures de début) sont des grilles de cases (Raster, une par graphique) : chaque jour du calendrier est une case pleine à la teinte de son niveau (les opacités du SVG, posées sur un fond foncé), et dans une barre empilée la frontière entre deux modèles tombe au huitième de case (part du bas en avant-plan, part du haut en fond). Le reste du terminal reste en glyphes : courbes d'une ligne, jauges en blocs, pastilles, bandeau. Sans Raster dans la table de la surface, le calendrier revient à · ░ ▒ ▓ █ et les colonnes aux huitièmes de bloc en texte.


Le bandeau

Une ligne, deux pendant un relevé.

Sur le terminal, compact (un émoji par libellé, « tok ») :

▍ Stats  ▰▰▰▰ 🔥 44 $/h · 💬 47,12 $ 101 M tok · 🌞 134 $ 210 M tok · 📅 1 179 $ 2,3 Md tok     ▸ /ccstats  x
          ⠋ Relevé ccusage : sessions et blocs de 5 h…

Sur le desktop, en mots :

▍ Stats  🔥 44 $/h · discussion 47,12 $ 101 M · aujourd'hui 134 $ 210 M · oct. 1 179 $ 2,3 Md     ▸ /ccstats  x

Dans l'ordre :

  1. Le rythme horaire du bloc de 5 h en cours (ccusage), avec son feu : une flamme SVG sur le desktop, quatre crans colorés sur le terminal (et 🔥 au dernier niveau). Absent hors d'un bloc.
  2. La discussion ouverte (💬 sur le terminal) : son coût d'après le moteur (le même que /cost, mis à jour à chaque tour) et ses tokens (voir l'onglet Discussion).
  3. Le jour (🌞) : coût et tokens. Un relevé d'un autre jour affiche son jour (« le 6 oct. », « 🌞 6 oct. »), pas 0 $. À partir de 64 colonnes.
  4. Le mois en cours (📅) : coût et tokens. À partir de 92 colonnes.
  5. L'unité après chaque nombre de tokens : « tok » sur le terminal à partir de 120 colonnes, « tokens » sur le desktop à partir de 150. L'âge du relevé à partir de 150 colonnes.

Les trois émojis occupent deux cellules et n'ont pas de sélecteur de variante : leur largeur est la même dans tous les terminaux qui affichent les émojis.

Les niveaux du rythme viennent des 45 blocs de 5 h du 9 septembre au 7 octobre 2026 (dépense par heure active : 1er quartile 6,9 $/h, médiane 13,5, 3e quartile 21,7, 9e décile 31,9, maximum 71). Ils sont fixés dans RATE_LEVELS (model.ts) :

NiveauRythmeFeu
calme< 7 $/hflamme éteinte, crans vides
posé7 à 14 $/hpetite flamme jaune, 1 cran
soutenu14 à 22 $/hflamme mandarine, 2 crans
intense22 à 32 $/hgrande flamme rouge-orangé, 3 crans
en feu≥ 32 $/htrois flammes et un halo, 4 crans et 🔥
  • « ▸ /ccstats » ouvre le panneau. « x » masque le bandeau pour la session ; la ligne d'état sous le prompt prend alors le relais. /ccstats show le réaffiche.

Le bandeau s'empile avec ceux des autres mods (git-board, flow-board, asana-board) sans les effacer. En tête, une barre « ▍ » et le nom du mod en bleu ciel, alignés avec ceux des autres bandeaux.


Commandes

CommandeEffet
/stats-board:ccstatsOuvre le panneau. Connue dès le premier message.
/ccstatsLa même commande, déclarée au démarrage du mod. Masquée du menu, utilisable tapée en entier.
/ccstats discussionOuvre l'onglet Discussion et, s'il n'y en a pas encore, lance le détail de ccusage sur la discussion ouverte.
/ccstats dayOuvre l'onglet Journée sur le jour du relevé (aussi journée, jour, today, aujourd'hui, récap).
/ccstats overview, trend, models, projects, sessions, rhythmOuvre le panneau sur cet onglet (les noms français marchent aussi : aperçu, évolution, modèles, projets, rythme).
/ccstats refreshOuvre le panneau et relance un relevé.
/ccstats hideMasque le bandeau ; la ligne d'état prend le relais.
/ccstats showRéaffiche le bandeau et ouvre le panneau.

Pas de /stats : c'est le nom de la commande intégrée de Claude Code.

Le skill recap

/stats-board:recap écrit le récap de l'usage dans la conversation, en Markdown. Claude lance skills/recap/recap.mjs avec l'identifiant de la discussion (${CLAUDE_SESSION_ID}), recopie sa sortie et ajoute au plus deux remarques. Tous les chiffres viennent du script.

PartieContenuSource
Cette discussionCoût, tokens et part lue en cache, sortie, heures et durée, projet, coût par modèle, sous-agents de workflow (inclus)ccusage claude session --since <7 jours> : sa ligne et celles de ses sous-agents
Aujourd'hui sur ce posteCoût et tokens, coût par modèle, tableau des projets (coût, part, tokens)ccusage claude daily --since <aujourd'hui> --instances --breakdown
Discussions actives aujourd'huiLes 5 plus chères (projet, heures, coût total), le reste regroupéLes sessions dont la dernière activité est aujourd'hui
Bloc de 5 h en coursCoût, rythme et son niveau (mêmes seuils que le bandeau), projectionccusage claude blocks --active
Autres postesCoût et tokens du jour, heure de publication, total de tous les postes~/.claude/claude-usage/machines/*.json
  • Le script ne passe pas par le mod : il marche même si le mod n'est pas chargé, et ne touche ni au relevé gardé ni au dépôt claude-usage.
  • Il lance ccusage comme le mod (--json --timezone <fuseau>, nouvel essai avec --offline si le barème en ligne échoue). Une quinzaine de secondes au premier appel, moins d'une seconde quand ccusage est en cache.
  • Sans identifiant de discussion, il prend la transcription la plus récente du dossier courant et le signale (« Discussion devinée »).
  • Le coût de la discussion est celui de ccusage : il peut différer un peu du 💬 du bandeau, qui est celui du moteur (/cost).
  • Le coût d'une discussion active aujourd'hui est son total, jours précédents compris.
  • Les noms de projet sont ceux du fichier publié par ce poste, ou le dernier segment d'un cwd des transcriptions, à condition que ce chemin redonne la clé du projet. Un projet nommé dans names.json (voir « Partage entre postes ») prend ce nom ; un worktree de Claude de ce projet s'affiche « <nom> · <worktree> » ; sans nom dans names.json, un worktree garde son nom seul. Un names.json illisible est ignoré sans avertissement (le panneau, lui, le signale).
  • Les formats (fmtCost, fmtTokens…), les seuils de rythme (RATE_LEVELS) et la lecture de names.json (parseLabels, LABEL_MAX) sont recopiés de model.ts : les garder alignés à la main.
Récap d'un parcours

Sur « récap du parcours », « combien a coûté le parcours », le skill totalise un parcours entier (discussion principale, sessions liées, sous-agents compris) sans chercher les identifiants à la main :

OptionSessions retenues
--parcours "<préfixe>"Celles dont le titre commence par le préfixe. Le titre est la dernière ligne custom-title de la transcription (après un renommage, c'est le nouveau), sinon la dernière ai-title, le titre automatique d'une session jamais renommée. Les sessions liées de flow-board portent « <titre du parcours, coupé au-delà de 28 signes>… › Groupe X · … » (linkedPrefix de flow-board) : une telle session est retenue aussi quand son début coupé commence le préfixe, si bien que le titre complet de la discussion principale suffit. Guillemets, «, », $, \ et accents graves sont ignorés de part et d'autre, comme flow-board les retire.
--worktrees <dossier du projet>Celles des worktrees groupe-* du projet (dossiers ~/.claude/projects/<projet>--claude-worktrees-groupe-*), plus la discussion principale si son identifiant est passé ; sinon le récap le signale. Lancé depuis un worktree, le dossier remonte au projet.
--since AAAA-MM-JJ, --until AAAA-MM-JJLa période : 60 jours jusqu'à aujourd'hui par défaut. Les worktrees groupe-* resservent d'un parcours à l'autre : avec --worktrees, donner les dates de début et de fin du parcours.

Les deux premières options se combinent (réunion des sessions). Un argument inconnu (préfixe sans guillemets, --since=…) arrête le script. Le récap donne le coût total, les tokens, le coût par modèle, puis une ligne par session : titre, projet, heures de début et de fin, durée (de la première à la dernière activité, pauses comprises), coût. Il ne lit que ccusage claude session --since <début> [--until <fin>] : ni journée, ni bloc de 5 h, ni autres postes. ccusage 20 compte les sous-agents (dossier subagents/ d'une transcription) dans la session qui les a lancés. Les titres se lisent par blocs depuis la fin de chaque transcription.


Données et relevés

DonnéeSourcePortée
Coût, tokens par jour, projet et modèlenpx ccusage@latest claude daily --instances --breakdown --jsonCe poste, publié
Sessions (début, fin, coût, modèles)npx ccusage@latest claude session --jsonCe poste, publié
Les autres postes~/.claude/claude-usage/machines/*.json (dépôt partagé)Leurs lignes et sessions ; Serveur : totaux par jour sur PolePredict
Blocs de 5 h (rythme, projection)npx ccusage@latest claude blocks --breakdown --jsonCe poste, tous projets
Messages, appels d'outils, heures~/.claude/stats-cache.json (cache de /stats, un jour de retard)Ce poste, tous projets
Nom d'affichage d'un projet~/.claude/claude-usage/names.json (dépôt partagé), écrit à la mainTous les postes ; jamais publié
Nom et dossier des projetsLe premier "cwd" des transcriptions (~/.claude/projects/<clé>/*.jsonl) qui redonne la clé du projet, à défaut le premier tout court, lu par NodeGardé une fois trouvé ; relu s'il ne redonne pas la clé

Chaque rapport passe --json --order asc --timezone <fuseau de la machine>. Le fuseau est lu par Node (Intl), qui donne aussi le jour local du mod.

RelevéQuand
Au démarrage3 s après, si le dernier relevé a plus de 20 min
À l'ouverture du panneauSi le dernier relevé a plus de 5 min
Après un tour de la conversationSi le dernier relevé a plus de 20 min
« ↻ Actualiser », /ccstats refreshToujours

Un relevé prend une quinzaine de secondes. Le premier rapport installe ccusage dans le cache de npx ; les deux suivants partent ensemble, avec la version de ccusage. Si le barème en ligne échoue, ccusage est relancé avec --offline, et l'en-tête affiche « prix hors ligne ».

Donnée gardéeEmplacementPortée
Dernier relevé$.store (snapshot) et état de sessionD'une session à l'autre
Onglet, période, machine, filtres, listes dépliées, bandeau masquéÉtat de session ($.state)Une discussion

Historique gardé. Claude Code efface ses journaux après cleanupPeriodDays jours, et ccusage ne voit alors plus ces jours. Le mod garde ceux de ce poste dans le relevé précédent qui sont plus anciens que le premier jour du nouveau, et le dit dans l'en-tête (« ↺ N jours gardés par le mod »). Il fait de même pour les sessions. Comme ce poste publie ce qu'il garde, son historique reste aussi dans claude-usage.

Partage entre postes

  • Nom du poste : une ligne dans ~/.claude/claude-usage/.machine (Portable, Mac…), propre au poste et ignorée par git. Sans nom, le mod s'affiche « Ce poste », ne publie rien et lit quand même les autres.
  • À chaque relevé :
  • écrire machines/<nom>.json, seulement si ses lignes, sessions ou projets ont changé (l'heure seule ne compte pas) ;
  • git add, puis git commit ;
  • git pull --rebase, puis git push s'il y a quelque chose à pousser ;
  • lire les fichiers des autres postes.
  • git sans invite (GIT_TERMINAL_PROMPT=0) : un échec (hors ligne, identifiants) devient un avertissement. Les fichiers déjà là sont lus quand même, et le relevé suivant réessaie.
  • Pas de conflit : chaque poste n'écrit que son fichier.
  • Rapprochement des projets : un projet d'un autre poste prend la clé du projet de ce poste qui a le même nom de dossier (sans la casse). Sinon, il garde la sienne, avec son dossier. Un worktree de Claude est rapproché sous le nom de son dépôt. Une session lancée par SSH depuis le desktop, que le poste qui l'a lancée garde sous ssh-<id>, est écartée de ce poste quand celui où elle a tourné la publie : elle ne compte qu'une fois. Deux projets différents du même nom sont donc additionnés. Les brouillons du desktop gardent leur clé et se regroupent seuls.
  • Historique de ce poste : avant de publier, le mod réunit tout ce qu'il connaît de ce poste. Il prend le relevé gardé, y compris celui d'avant la 0.3.0 et les lignes encore marquées « Ce poste », et le fichier que ce poste a déjà publié. Ni une mise à jour, ni un premier nommage, ni un nouveau clone ne perdent donc les jours que Claude Code a effacés. Un poste resté inactif plus longtemps que ses journaux garde aussi son historique au lieu de publier un fichier vide.
  • Commit raté (par exemple sans identité git) : le relevé suivant voit le fichier non commité et réessaie. Un pull --rebase qui échoue est annulé (rebase --abort).
  • Plusieurs sessions sur un même poste : elles font parfois leur relevé en même temps, et git refuse alors la seconde : verrou de l'index (index.lock), ou cannot lock ref quand son pull trouve origin/main déjà avancé par l'autre. Celle-ci réessaie deux fois, à 2 puis 4 s. Si ça bloque encore, elle laisse la publication à l'autre, sans avertissement. Seul un verrou d'index qui date de plus de 10 min (git arrêté en route) est signalé, avec son chemin.
  • Nom d'affichage d'un projet : names.json, à la racine du dépôt de partage, donne un autre nom à un projet sans déplacer son dossier, par exemple { "qwen-image-local": "Chambre noire" }. La clé est le nom du dossier (sans la casse, le dernier segment de son chemin, aussi pour le dossier personnel), le même sur tous les postes ; la valeur, le nom affiché (60 caractères au plus). Le panneau et /stats-board:recap l'affichent partout, worktrees compris. Le rapprochement des postes et les fichiers publiés gardent le nom du dossier : retirer une ligne rend l'ancien nom au relevé suivant. Ce fichier s'écrit à la main, puis commit et push dans le dépôt de partage : une modification non commitée bloque la publication du poste (le pull --rebase refuse), avec un avertissement, jusqu'au commit. Un fichier illisible devient un avertissement de l'en-tête ; un BOM en tête (PowerShell) est accepté.
  • Renommer un poste : changer la ligne de .machine, et renommer aussi son fichier publié (`machines/
Source 6 files
hooks/register.tsx 783 lines
1// Panneau de statistiques Claude Code (/ccstats) et bandeau au-dessus du prompt : récap de la
2// journée, coût équivalent API, tokens, modèles, projets, sessions, blocs de 5 h, calendrier et rythme.
3//
4// - Ce module est le seul à parler au moteur (`$`) : état, hooks, ccusage, fichiers.
5//   Le dessin est dans view.tsx, les données pures dans model.ts, les SVG dans svg.ts.
6// - Les prix viennent de ccusage (`npx ccusage@latest claude daily|session|blocks --json`) ;
7//   les messages, appels d'outils et heures du cache de /stats (`~/.claude/stats-cache.json`).
8// - Partage entre postes : chaque poste publie ses lignes et ses sessions dans le dépôt git
9//   `~/.claude/claude-usage` (`machines/<nom>.json`, nom du poste dans `.machine`) et lit ceux
10//   des autres ; l'ancien serveur y a un fichier figé. Sans ce dépôt, le mod reste local.
11// - Un relevé prend une quinzaine de secondes : il part au démarrage si le dernier a plus de
12//   20 min, à l'ouverture du panneau s'il a plus de 5 min, après un tour s'il a plus de
13//   20 min, et sur « ↻ Actualiser ». Le dernier relevé est gardé dans `$.store`.
14// - `/stats-board:ccstats` (commands/ccstats.md) est connue dès le premier message ;
15//   `/ccstats`, déclarée au démarrage, sort du menu mais reste utilisable tapée en entier.
16//   Pas de `/stats` : la commande intégrée de Claude Code porte ce nom.
17
18import { atom, read, update } from 'claude-code'
19import type { Register } from 'claude-code'
20
21import type { Discussion, Meta, Notice, ProjectInfo, Published, SessionRow, Snapshot, Tab, Ui, UsageRow } from '../types'
22import {
23  LOCAL_LABEL,
24  SCHEMA,
25  activityOf,
26  blocksOf,
27  cwdOf,
28  dayOf,
29  discussionOf,
30  fmtCost,
31  isStale,
32  keepHistory,
33  keepSessions,
34  machineFile,
35  mergeMachines,
36  nameOfPath,
37  ownHistory,
38  parseJson,
39  parseLabels,
40  parsePublished,
41  projectKey,
42  publishedOf,
43  rowsOfDaily,
44  sessionsOf,
45  statusText,
46  sum,
47  unique,
48  unpricedOf,
49  withLabels,
50} from './model.ts'
51import { drawBand, drawPane, kitOf, type Actions, type Caps, type View } from './view.tsx'
52
53const PANE = 'ccstats'
54const PANE_TITLE = 'Stats Claude Code'
55/** La commande déclarée par `commands/ccstats.md`, préfixée du nom du plugin. */
56const STATIC_CMD = 'stats-board:ccstats'
57const ARG_HINT = '[discussion|day|overview|trend|models|projects|sessions|rhythm|refresh|hide|show]'
58const TAB_LABEL: Record<Tab, string> = { discussion: 'Discussion', day: 'Journée', overview: 'Aperçu', trend: 'Évolution', models: 'Modèles', projects: 'Projets', sessions: 'Sessions', rhythm: 'Rythme' }
59const TAB_WORDS: Record<string, Tab> = {
60  discussion: 'discussion',
61  day: 'day',
62  today: 'day',
63  jour: 'day',
64  journee: 'day',
65  journée: 'day',
66  aujourdhui: 'day',
67  "aujourd'hui": 'day',
68  recap: 'day',
69  récap: 'day',
70  overview: 'overview',
71  apercu: 'overview',
72  aperçu: 'overview',
73  trend: 'trend',
74  evolution: 'trend',
75  évolution: 'trend',
76  costs: 'trend',
77  couts: 'trend',
78  coûts: 'trend',
79  models: 'models',
80  modeles: 'models',
81  modèles: 'models',
82  projects: 'projects',
83  projets: 'projects',
84  sessions: 'sessions',
85  rhythm: 'rhythm',
86  rythme: 'rhythm',
87}
88
89/** Clés de `$.store`, d'une session à l'autre. */
90const STORE_SNAPSHOT = 'snapshot'
91/** Relevé automatique (démarrage, fin de tour) quand le dernier a plus de 20 min. */
92const AUTO_MS = 20 * 60_000
93/** Relevé à l'ouverture du panneau quand le dernier a plus de 5 min. */
94const OPEN_MS = 5 * 60_000
95/** Le dépôt git partagé entre les postes, sous le dossier de Claude Code. */
96const USAGE_DIR = 'claude-usage'
97/** Le nom de ce poste, une ligne, propre au poste (ignoré par git dans le dépôt partagé). */
98const MACHINE_FILE = '.machine'
99/** Les noms d'affichage des projets, communs aux postes, à la racine du dépôt partagé. */
100const LABELS_FILE = 'names.json'
101/** git sans invite : un push sans identifiants échoue au lieu d'attendre. */
102const GIT_ENV = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
103/** Plafond du relevé gardé dans `$.store` (4 Mio en tout pour le plugin). */
104const STORE_LIMIT = 3_000_000
105
106const EMPTY_UI: Ui = {
107  tab: 'overview',
108  period: 'all',
109  machine: 'all',
110  metric: 'cost',
111  grain: 'day',
112  project: null,
113  model: null,
114  sessionSort: 'cost',
115  expanded: { models: false, projects: false, sessions: false, days: false },
116  day: null,
117}
118const EMPTY_META: Meta = { loading: null, error: null, startedAt: null }
119const EMPTY_DISCUSSION: Discussion = { id: null, live: null, detail: null, turns: [], loading: false, error: null }
120
121// Gardés par l'hôte : survivent au rechargement à chaud du module.
122const snapshot = atom({ plugin: 'stats-board', key: 'snapshot' } as const, null as Snapshot | null)
123const ui = atom({ plugin: 'stats-board', key: 'ui' } as const, EMPTY_UI)
124const meta = atom({ plugin: 'stats-board', key: 'meta' } as const, EMPTY_META)
125const notice = atom({ plugin: 'stats-board', key: 'notice' } as const, null as Notice)
126// Bandeau masqué : le temps de la session, comme git-board.
127const isBandHidden = atom({ plugin: 'stats-board', key: 'isBandHidden' } as const, false)
128const here = atom({ plugin: 'stats-board', key: 'here' } as const, null as string | null)
129const discussion = atom({ plugin: 'stats-board', key: 'discussion' } as const, EMPTY_DISCUSSION)
130
131// Le contrat `Elements` du moteur : pas de `Svg` sur le terminal. La table de `$.ui.resolve`
132// le contient quand même (fragment vide) : on ne peut pas s'y fier.
133const capsOf = (surface: string): Caps => ({ hasSvg: surface !== 'terminal' })
134
135type Run = { exitCode: number; stdout: string; stderr: string }
136type Json = Record<string, any>
137
138// État du module, perdu au rechargement : rien que des optimisations.
139let inFlight: Promise<void> | null = null
140let detailInFlight: Promise<void> | null = null
141/** Le lanceur de npx qui a marché : `npx` direct, `npx.cmd`, ou `cmd.exe /c npx` sous Windows. */
142let launcher: string[] | null = null
143let ticks = 0
144
145/** Un effet d'interface raté (pas d'écran, surface sans panneau) ne doit jamais gêner le relevé. */
146const quietly = async (effect: () => unknown) => {
147  try {
148    await effect()
149  } catch {
150    // Rien à afficher ici : l'état, lui, est déjà écrit.
151  }
152}
153
154const firstLine = (text: string): string =>
155  text
156    .split(/\r?\n/)
157    .map((l) => l.trim())
158    .find((l) => l && !/^\s*$/.test(l)) ?? ''
159
160const messageOf = (err: unknown): string => String((err as { message?: unknown })?.message ?? err)
161
162// ─── Commande /ccstats et /stats-board:ccstats ──────────────────────────────
163
164// `/ccstats` n'existe qu'après le démarrage du mod ; `/stats-board:ccstats`, déclarée par
165// `commands/ccstats.md`, est connue dès le premier message. Les deux reçoivent la même
166// réponse, sans passer par le modèle. Aucun état posé par `session.start` n'est supposé ici.
167const answer = async ($: any, e: { args: string }) => {
168  const word = e.args.trim().toLowerCase()
169  if (word === 'hide') {
170    await setBandHidden($, true)
171    return { text: 'Bandeau Stats masqué (/ccstats show pour le réafficher).' }
172  }
173  let text = 'Panneau Stats ouvert.'
174  if (word === 'show') {
175    await setBandHidden($, false)
176    text = 'Bandeau Stats réaffiché, panneau Stats ouvert.'
177  }
178  const tab = TAB_WORDS[word]
179  if (tab) {
180    // `/ccstats day` montre la journée du relevé, même après un jour choisi dans l'onglet.
181    await update($, ui, (x) => ({ ...x, tab, ...(tab === 'day' ? { day: null } : {}) }))
182    text = `Panneau Stats ouvert (onglet ${TAB_LABEL[tab]}).`
183    // L'onglet Discussion sans détail encore : ccusage sur cette seule discussion.
184    if (tab === 'discussion' && !(await read($, discussion)).detail) $.clock.after(0, () => void refreshDiscussion($))
185  }
186  await quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
187  const s = await read($, snapshot)
188  const isForced = word === 'refresh'
189  if (isForced) text = 'Panneau Stats ouvert : relevé ccusage relancé.'
190  // Par une minuterie : le relevé (une quinzaine de secondes) survit à la fin de la commande.
191  if (isForced || !s || (await $.clock.now()) - s.fetchedAt > OPEN_MS) $.clock.after(0, () => void refresh($, isForced))
192  return { text }
193}
194
195// `/ccstats` reste utilisable tapée en entier, mais sort du menu : `/stats-board:ccstats`,
196// déjà là avant le premier message, y suffit et le doublon disparaît.
197const hideDuplicate = async ($: any, e: any, next: any) => ({ ...(await next(e)), isHidden: true })
198
199export const register: Register = (on) => {
200  on('session.start', async ($, e, next) => {
201    const result = await next(e)
202    // Un enregistrement refusé ne doit empêcher ni la minuterie ni le relevé.
203    await quietly(() =>
204      $.command.register({
205        name: 'ccstats',
206        description: 'Statistiques Claude Code : journée, coût (ccusage), tokens, modèles, projets, sessions, rythme',
207        argumentHint: ARG_HINT,
208      }),
209    )
210    // « Ce projet » : la clé ccusage du dossier de la session.
211    const cwd = await Promise.resolve($.session.cwd()).catch(() => null)
212    if (typeof cwd === 'string' && cwd) await update($, here, () => projectKey(cwd))
213    // Un relevé d'un autre format (gardé par l'hôte au rechargement) est écarté, puis refait.
214    const current = await read($, snapshot)
215    if (current && !isSnapshot(current)) await update($, snapshot, () => null)
216    // Le dernier relevé, gardé d'une session à l'autre : bandeau et panneau l'ont tout de suite.
217    if (!(await read($, snapshot))) {
218      const stored = await Promise.resolve($.store.get(STORE_SNAPSHOT)).catch(() => undefined)
219      if (isSnapshot(stored)) await update($, snapshot, () => stored)
220    }
221    // La discussion ouverte : coût et contexte d'après le moteur, tout de suite.
222    await quietly(() => readLive($))
223    // Toutes les 5 s : les secondes d'un relevé en cours ; à la minute, les âges et le bloc de 5 h.
224    $.clock.every(5_000, () => void tick($))
225    await syncStatus($)
226    const s = await read($, snapshot)
227    if (!s || (await $.clock.now()) - s.fetchedAt > AUTO_MS) $.clock.after(3_000, () => void refresh($, false))
228    return result
229  })
230
231  on('command.run', { command: 'ccstats' }, answer)
232  on('command.describe', { command: 'ccstats' }, hideDuplicate)
233  // En panne, le mod s'efface : la commande statique dit alors qu'il n'est pas actif.
234  on('command.run', { command: STATIC_CMD }, answer).catch(() => undefined)
235
236  // Après un tour de la conversation principale : ses tokens et le coût du moteur pour la
237  // discussion, puis un relevé si le dernier a vieilli.
238  on('turn.complete', async ($, e, next) => {
239    const result = await next(e)
240    if (!e.agentId) {
241      const u = (result as { usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } }).usage
242      if (u) {
243        const tokens = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
244        const at = await $.clock.now()
245        await update($, discussion, (d) => ({ ...d, turns: [...d.turns, { at, tokens }].slice(-500) }))
246      }
247      await quietly(() => readLive($))
248      const s = await read($, snapshot)
249      if (s && (await $.clock.now()) - s.fetchedAt > AUTO_MS) $.clock.after(1_000, () => void refresh($, false))
250    }
251    return result
252  })
253
254  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255    const view = await readView($)
256    const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
257    return drawPane(kit, view, actionsFor($)) as any
258  })
259
260  // Le bandeau se compose avec ceux des autres plugins (git-board, flow-board…) : le leur reste
261  // au-dessus, celui de Stats dessous ; jamais seul quand un autre bandeau existe.
262  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
263    if (e.props.hasSurvey) return next(e)
264    // Lire AVANT tout retour anticipé : la lecture abonne le bandeau aux changements.
265    const view = await readView($)
266    if (view.isBandHidden) return next(e)
267    const below = await next(e)
268    const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
269    const own = drawBand(kit, view, actionsFor($)) as any
270    if (!own) return below
271    if (below.type === 'engine') return own
272    const { Box } = kit
273    return (
274      <Box flexDirection="column" width={e.props.bodyColumns}>
275        {below}
276        {own}
277      </Box>
278    )
279  })
280}
281
282// ─── Vue et actions ─────────────────────────────────────────────────────────
283
284async function readView($: any): Promise<View> {
285  return {
286    snapshot: await read($, snapshot),
287    ui: await read($, ui),
288    meta: await read($, meta),
289    notice: await read($, notice),
290    isBandHidden: await read($, isBandHidden),
291    here: await read($, here),
292    discussion: await read($, discussion),
293    now: await $.clock.now(),
294  }
295}
296
297function actionsFor($: any): Actions {
298  return {
299    setUi: (patch) => update($, ui, (x) => ({ ...x, ...patch })),
300    toggleExpanded: (list) => update($, ui, (x) => ({ ...x, expanded: { ...x.expanded, [list]: !x.expanded[list] } })),
301    // ↻ de l'en-tête : le relevé complet et, en même temps, le détail de la discussion.
302    refresh: () => Promise.all([refresh($, true), refreshDiscussion($)]),
303    refreshDiscussion: () => refreshDiscussion($),
304    dismissNotice: () => update($, notice, () => null),
305    hideBand: async () => {
306      await setBandHidden($, true)
307      await quietly(() => $.ui.toast('Bandeau Stats masqué : /ccstats show pour le réafficher'))
308    },
309    openPane: async () => {
310      await quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
311      const s = await read($, snapshot)
312      if (!s || (await $.clock.now()) - s.fetchedAt > OPEN_MS) void refresh($, false)
313    },
314  }
315}
316
317async function setBandHidden($: any, hidden: boolean) {
318  await update($, isBandHidden, () => hidden)
319  await syncStatus($)
320}
321
322/** La ligne d'état sous le prompt ne sert que de relais quand le bandeau est masqué. */
323async function syncStatus($: any) {
324  const s = await read($, snapshot)
325  const hidden = await read($, isBandHidden)
326  let text: string | undefined
327  if (s && hidden) {
328    const now = await $.clock.now()
329    const today = dayOf(now, s.tzOffset)
330    const active = s.blocks.find((b) => b.isActive && b.end > now) ?? null
331    const d = await read($, discussion)
332    text = statusText({ perHour: active?.costPerHour ?? null, discussion: d.live?.cost ?? null, today: sum(s.rows.filter((r) => r.date === today)).cost })
333  }
334  await quietly(() => $.ui.status(text))
335}
336
337/** Redessine pour faire avancer les secondes d'un relevé (toutes les 5 s) et les âges (à la minute). */
338async function tick($: any) {
339  ticks += 1
340  // Un relevé ou un détail de discussion en cours : l'indicateur du terminal avance.
341  const isBusy = (await read($, meta)).loading !== null || (await read($, discussion)).loading
342  if (isBusy || ticks % 12 === 0) await quietly(() => $.ui.invalidate('ui.render'))
343}
344
345// ─── Relevé ─────────────────────────────────────────────────────────────────
346
347/** Un relevé à la fois ; un autre demandé entre-temps attend celui en cours. */
348function refresh($: any, isAsked: boolean): Promise<void> {
349  if (inFlight) return inFlight
350  inFlight = (async () => {
351    try {
352      await load($, isAsked)
353    } catch (err) {
354      const text = `Relevé impossible : ${messageOf(err)}`
355      await update($, meta, (m) => ({ ...m, loading: null, startedAt: null, error: text }))
356      await say($, 'error', text)
357    } finally {
358      inFlight = null
359    }
360  })()
361  return inFlight
362}
363
364async function load($: any, isAsked: boolean) {
365  const startedAt = await $.clock.now()
366  const step = (loading: string) => update($, meta, (m) => ({ ...m, loading, startedAt: m.startedAt ?? startedAt }))
367  await step('Relevé ccusage : consommation par jour, projet et modèle…')
368  const stored = await read($, snapshot)
369  const previous = isSnapshot(stored) ? stored : null
370  const tz = await timezone($)
371  const base = ['--json', '--order', 'asc', ...(tz.name ? ['--timezone', tz.name] : [])]
372
373  // Le premier appel installe ccusage dans le cache de npx ; les suivants partent ensemble.
374  const daily = await ccusage($, ['claude', 'daily', '--instances', '--breakdown', ...base], false)
375  await step('Relevé ccusage : sessions et blocs de 5 h…')
376  const [sessions, blocks, version] = await Promise.all([
377    ccusage($, ['claude', 'session', ...base], daily.isOffline),
378    ccusage($, ['claude', 'blocks', '--breakdown', ...base], daily.isOffline),
379    ccusageVersion($),
380  ])
381  await step('Lecture du cache de /stats et des projets…')
382  const dir = await claudeDir($)
383  const home = await homeDir($)
384  const warnings: string[] = []
385  const name = await machineName($, dir)
386  const machine = name ?? LOCAL_LABEL
387  const activity = await readActivity($, dir)
388
389  // L'historique de ce poste : relevé gardé (même d'avant la 0.3.0) et fichier déjà publié.
390  const legacy = previous ? null : await Promise.resolve($.store.get(STORE_SNAPSHOT)).catch(() => null)
391  const history = ownHistory(machine, { previous, legacy, published: await readOwn($, dir, name) })
392  const kept = keepHistory(rowsOfDaily(daily.json, machine), history.rows, machine)
393  const ownSessions: SessionRow[] = keepSessions(sessionsOf(sessions.json, machine), history.sessions, machine)
394    .sort((a, b) => a.last - b.last)
395    .slice(-800)
396  let blockRows = blocksOf(blocks.json)
397  const keys = unique([...kept.rows.map((r) => r.project), ...ownSessions.map((s) => s.project)])
398  const ownProjects = await resolveProjects($, keys, previous?.projects ?? {}, dir, home)
399
400  await step('Partage entre postes : publication et lecture des autres postes…')
401  const own = { machine, tzOffset: tz.offset, rows: kept.rows, sessions: ownSessions, projects: ownProjects }
402  const others = await share($, dir, name, own, warnings)
403  const merged = mergeMachines(own, others)
404
405  let snap: Snapshot = {
406    schema: SCHEMA,
407    fetchedAt: await $.clock.now(),
408    tzOffset: tz.offset,
409    rows: merged.rows,
410    sessions: merged.sessions.slice(-1500),
411    blocks: blockRows.slice(-400),
412    activity,
413    projects: withLabels(merged.projects, await readLabels($, dir, warnings)),
414    unpriced: unpricedOf(daily.json, sessions.json),
415    isOffline: daily.isOffline || sessions.isOffline,
416    machine,
417    machines: merged.machines,
418    sessionless: merged.sessionless,
419    version,
420    keptDays: kept.kept,
421    warnings,
422  }
423  // Le store du plugin tient en 4 Mio : au-delà, les plus vieilles sessions et blocs cèdent.
424  while (JSON.stringify(snap).length > STORE_LIMIT && (snap.sessions.length > 100 || snap.blocks.length > 50)) {
425    blockRows = snap.blocks.slice(Math.floor(snap.blocks.length / 4))
426    snap = { ...snap, sessions: snap.sessions.slice(Math.floor(snap.sessions.length / 4)), blocks: blockRows }
427  }
428  // Encore trop gros : le store ne garde que ce poste ; les autres reviennent au relevé suivant.
429  const toStore = JSON.stringify(snap).length > STORE_LIMIT ? { ...snap, rows: snap.rows.filter((r) => r.machine === machine), sessions: snap.sessions.filter((s) => s.machine === machine), machines: [machine] } : snap
430
431  await update($, snapshot, () => snap)
432  await update($, meta, () => EMPTY_META)
433  await quietly(() => $.store.set(STORE_SNAPSHOT, toStore))
434  await syncStatus($)
435  if (isAsked) await say($, 'ok', `✓ Relevé à jour : ${fmtCost(sum(snap.rows).cost)} au total, ${daily.isOffline ? 'prix hors ligne' : 'prix en ligne'}.`)
436}
437
438// ─── Partage entre postes ───────────────────────────────────────────────────
439
440/** Le nom de ce poste, lu dans `claude-usage/.machine` ; null s'il n'est pas réglé. */
441async function machineName($: any, dir: string | null): Promise<string | null> {
442  if (!dir) return null
443  try {
444    const name = firstLine(await $.fs.read(`${dir}/${USAGE_DIR}/${MACHINE_FILE}`))
445    return name || null
446  } catch {
447    return null
448  }
449}
450
451/** Les noms d'affichage de `names.json` dans le dépôt partagé ; absent, aucun ; illisible, un avertissement. */
452async function readLabels($: any, dir: string | null, warnings: string[]): Promise<Record<string, string>> {
453  if (!dir) return {}
454  const file = `${dir}/${USAGE_DIR}/${LABELS_FILE}`
455  if (!(await Promise.resolve($.fs.exists(file)).catch(() => false))) return {}
456  try {
457    // Un fichier écrit par PowerShell commence par un BOM, que JSON.parse refuse.
458    const labels = parseLabels(JSON.parse(String(await $.fs.read(file)).replace(/^\uFEFF/, '')))
459    if (labels) return labels
460    warnings.push(`Noms des projets ignorés : ${USAGE_DIR}/${LABELS_FILE} n’est pas un objet { "dossier": "nom" }.`)
461  } catch (err) {
462    warnings.push(`Noms des projets illisibles (${USAGE_DIR}/${LABELS_FILE}) : ${messageOf(err)}`)
463  }
464  return {}
465}
466
467/** Le fichier que ce poste a déjà publié, s'il existe : son historique survit à un nouveau clone ou à une mise à jour. */
468async function readOwn($: any, dir: string | null, name: string | null): Promise<Published | null> {
469  if (!dir || !name) return null
470  try {
471    return parsePublished(JSON.parse(await $.fs.read(`${dir}/${USAGE_DIR}/machines/${machineFile(name)}`)))
472  } catch {
473    return null
474  }
475}
476
477async function git($: any, repo: string, args: string[]): Promise<Run> {
478  return $.process.run(['git', '-C', repo, ...args], { timeoutMs: 60_000, env: GIT_ENV })
479}
480
481/**
482 * Publie les usages de ce poste dans le dépôt partagé, puis lit ceux des autres. Chaque poste
483 * n'écrit que son fichier : `pull --rebase` ne peut pas entrer en conflit. Un échec de git
484 * (hors ligne, identifiants) devient un avertissement ; les fichiers déjà là sont lus quand même.
485 */
486async function share($: any, dir: string | null, name: string | null, own: Parameters<typeof publishedOf>[0], warnings: string[]): Promise<Published[]> {
487  if (!dir) return []
488  const repo = `${dir}/${USAGE_DIR}`
489  if (!(await Promise.resolve($.fs.exists(`${repo}/.git`)).catch(() => false))) {
490    warnings.push(`Partage entre postes désactivé : pas de dépôt ${USAGE_DIR} dans le dossier de Claude Code (/setup:install le clone).`)
491    return []
492  }
493  if (!name) warnings.push(`Ce poste n’a pas de nom : ses usages ne sont pas publiés (/setup:install, ou une ligne dans ${USAGE_DIR}/${MACHINE_FILE}).`)
494  // Plusieurs sessions de Claude Code font leur relevé en même temps sur ce poste : git refuse
495  // alors la seconde (`index.lock`), ou son `pull` trouve `origin/main` déjà avancé par l'autre
496  // (`cannot lock ref`). On réessaie un peu plus tard ; si ça bloque encore, l'autre session
497  // publie les mêmes données, sauf si le verrou d'index traîne depuis longtemps (git planté).
498  for (let attempt = 1; ; attempt++) {
499    try {
500      if (name) await publish($, repo, name, own)
501      else {
502        const pull = await git($, repo, ['pull', '--ff-only'])
503        if (pull.exitCode !== 0) throw new Error(firstLine(pull.stderr) || `git pull : code ${pull.exitCode}`)
504      }
505      break
506    } catch (err) {
507      const isRefRace = /cannot lock ref/i.test(messageOf(err))
508      const isLocked = isRefRace || /\.lock\b|another git process/i.test(messageOf(err))
509      if (isLocked && attempt < 3) {
510        await $.clock.sleep(2_000 * attempt)
511        continue
512      }
513      if (!isLocked) warnings.push(`Partage entre postes : ${messageOf(err)}`)
514      else if (!isRefRace && (await isStaleLock($, repo))) warnings.push(`Partage entre postes : un verrou git traîne depuis plus de 10 min (${USAGE_DIR}/.git/index.lock, git a dû s’arrêter en route) ; le supprimer débloque la publication.`)
515      break
516    }
517  }
518  return readOthers($, repo, name, warnings)
519}
520
521/** Le verrou d'index de git date de plus de 10 min : aucun git ne tourne plus, il est abandonné. */
522async function isStaleLock($: any, repo: string): Promise<boolean> {
523  try {
524    const entries: { name: string; mtimeMs: number }[] = await $.fs.list(`${repo}/.git`)
525    const lock = entries.find((e) => e.name === 'index.lock')
526    return !!lock && (await $.clock.now()) - lock.mtimeMs > 10 * 60_000
527  } catch {
528    return false
529  }
530}
531
532async function publish($: any, repo: string, name: string, own: Parameters<typeof publishedOf>[0]) {
533  const file = `machines/${machineFile(name)}`
534  const body = publishedOf(own)
535  // Rien de neuf depuis la dernière publication : ni écriture, ni commit (l'heure seule ne compte pas).
536  const before = await Promise.resolve($.fs.read(`${repo}/${file}`)).catch(() => null)
537  let isSame = false
538  try {
539    const { updatedAt: _, ...old } = JSON.parse(before ?? 'null') ?? {}
540    isSame = JSON.stringify(old) === JSON.stringify(body)
541  } catch {
542    // Fichier absent ou abîmé : il est réécrit.
543  }
544  if (!isSame) await $.fs.write(`${repo}/${file}`, JSON.stringify({ ...body, updatedAt: await $.clock.now() }))
545  // Pas commité, cette fois ou une précédente (commit raté) : on (re)tente, sinon le rebase refuserait.
546  const pending = await git($, repo, ['status', '--porcelain', '--', file])
547  if (pending.exitCode === 0 && pending.stdout.trim()) {
548    const add = await git($, repo, ['add', '--', file])
549    if (add.exitCode !== 0) throw new Error(`git add : ${firstLine(add.stderr) || `code ${add.exitCode}`}`)
550    const commit = await git($, repo, ['commit', '-q', '-m', `usage: ${name}`, '--', file])
551    if (commit.exitCode !== 0) throw new Error(`git commit : ${firstLine(commit.stderr) || firstLine(commit.stdout) || `code ${commit.exitCode}`}`)
552  }
553  const pull = await git($, repo, ['pull', '--rebase', '-q'])
554  if (pull.exitCode !== 0) {
555    // Un rebase arrêté en route bloquerait tous les relevés suivants : on revient à l'état d'avant.
556    await git($, repo, ['rebase', '--abort']).catch(() => undefined)
557    throw new Error(`git pull : ${firstLine(pull.stderr) || `code ${pull.exitCode}`}`)
558  }
559  // Sans branche suivie (`@{u}` inconnu), on pousse quand même.
560  const ahead = await git($, repo, ['rev-list', '--count', '@{u}..HEAD'])
561  if (ahead.exitCode !== 0 || Number(ahead.stdout.trim()) > 0) {
562    const push = await git($, repo, ['push', '-q'])
563    if (push.exitCode !== 0) throw new Error(`git push : ${firstLine(push.stderr) || `code ${push.exitCode}`}`)
564  }
565}
566
567/** Les fichiers publiés des autres postes (`machines/*.json`), sauf celui de ce poste. */
568async function readOthers($: any, repo: string, name: string | null, warnings: string[]): Promise<Published[]> {
569  const own = name ? machineFile(name) : null
570  let entries: { name: string; kind: string }[] = []
571  try {
572    entries = await $.fs.list(`${repo}/machines`)
573  } catch {
574    return []
575  }
576  const out: Published[] = []
577  for (const e of entries) {
578    if (e.kind !== 'file' || !e.name.endsWith('.json') || e.name === own) continue
579    try {
580      const p = parsePublished(JSON.parse(await $.fs.read(`${repo}/machines/${e.name}`)))
581      if (p) out.push(p)
582      else warnings.push(`Usages d’un autre poste ignorés : machines/${e.name} n’a pas le format attendu.`)
583    } catch (err) {
584      warnings.push(`Usages d’un autre poste illisibles (machines/${e.name}) : ${messageOf(err)}`)
585    }
586  }
587  return out.sort((a, b) => a.machine.localeCompare(b.machine))
588}
589
590// ─── Discussion ouverte ─────────────────────────────────────────────────────
591
592/** La discussion ouverte d'après le moteur : son identifiant, son coût (comme /cost), son contexte, ses tours. */
593async function readLive($: any) {
594  const id: string = await $.session.id()
595  const u = await $.session.usage()
596  const turns = await Promise.resolve($.session.turns()).catch(() => 0)
597  const model = await Promise.resolve($.session.model()).catch(() => null)
598  const live: NonNullable<Discussion['live']> = {
599    cost: typeof u?.cost?.usd === 'number' ? u.cost.usd : null,
600    contextPercent: typeof u?.context?.percent === 'number' ? u.context.percent : null,
601    contextTokens: typeof u?.context?.tokens === 'number' ? u.context.tokens : null,
602    window: typeof u?.context?.window === 'number' ? u.context.window : null,
603    turns: typeof turns === 'number' ? turns : 0,
604    model: typeof model === 'string' ? model : null,
605    startedAt: typeof u?.startedAt === 'number' ? u.startedAt : null,
606    at: await $.clock.now(),
607  }
608  // Une autre discussion (après /clear) : son détail et ses tours repartent de zéro.
609  await update($, discussion, (d) => (d.id === id ? { ...d, live } : { ...EMPTY_DISCUSSION, id, live }))
610}
611
612/** Le détail de la discussion ouverte (`ccusage claude session --id`) : quelques secondes, un à la fois. */
613function refreshDiscussion($: any): Promise<void> {
614  if (detailInFlight) return detailInFlight
615  detailInFlight = (async () => {
616    try {
617      await update($, discussion, (d) => ({ ...d, loading: true, error: null }))
618      await quietly(() => readLive($))
619      const id: string = await $.session.id()
620      const res = await npx($, ['-y', 'ccusage@latest', 'claude', 'session', '--id', id, '--json'])
621      if (res.exitCode !== 0) throw new Error(firstLine(res.stderr) || firstLine(res.stdout) || `code ${res.exitCode}`)
622      // `null` : ccusage ne trouve pas encore la discussion (aucun appel écrit dans sa transcription).
623      const parsed = res.stdout.trim() === 'null' ? { cost: 0, tokens: 0, entries: [] } : discussionOf(parseJson(res.stdout))
624      const fetchedAt = await $.clock.now()
625      await update($, discussion, (d) => ({ ...(d.id === id ? d : { ...EMPTY_DISCUSSION, id }), detail: { fetchedAt, ...parsed }, loading: false, error: null }))
626    } catch (err) {
627      await update($, discussion, (d) => ({ ...d, loading: false, error: `Détail de la discussion impossible : ${messageOf(err)}` }))
628    } finally {
629      detailInFlight = null
630    }
631  })()
632  return detailInFlight
633}
634
635/** Lance npx, en essayant les lanceurs connus de Windows ; ne passe au suivant que si le précédent ne démarre pas. */
636async function npx($: any, args: string[]): Promise<Run> {
637  const tries = launcher ? [launcher] : [['npx'], ['npx.cmd'], ['cmd.exe', '/d', '/s', '/c', 'npx']]
638  // L'erreur citée est celle de `npx` lui-même : les lanceurs suivants n'existent que sous Windows.
639  let first: unknown = null
640  for (const l of tries) {
641    try {
642      const res: Run = await $.process.run([...l, ...args], {
643        timeoutMs: 240_000,
644        env: { NO_COLOR: '1', FORCE_COLOR: '0', npm_config_update_notifier: 'false', npm_config_fund: 'false' },
645      })
646      launcher = l
647      return res
648    } catch (err) {
649      first ??= err
650      // Lancé mais trop lent : un autre lanceur ne ferait pas mieux.
651      if (/time|still running|délai/i.test(messageOf(err))) throw new Error(`ccusage n'a pas répondu à temps (${messageOf(err)})`)
652    }
653  }
654  throw new Error(`npx introuvable : Node.js est-il installé ? (${messageOf(first)})`)
655}
656
657/** Une commande ccusage en JSON ; en cas d'échec avec les prix en ligne, un second essai avec les prix embarqués. */
658async function ccusage($: any, args: string[], isOffline: boolean): Promise<{ json: Json; isOffline: boolean }> {
659  const once = async (offline: boolean) => {
660    const res = await npx($, ['-y', 'ccusage@latest', ...args, ...(offline ? ['--offline'] : [])])
661    if (res.exitCode !== 0) throw new Error(`ccusage ${args.slice(0, 2).join(' ')} : ${firstLine(res.stderr) || firstLine(res.stdout) || `code ${res.exitCode}`}`)
662    // Sans réseau, ccusage retombe seul sur ses prix embarqués et le dit sur stderr.
663    return { json: parseJson(res.stdout), isOffline: offline || /embedded pricing|failed to fetch/i.test(res.stderr) }
664  }
665  if (isOffline) return once(true)
666  try {
667    return await once(false)
668  } catch {
669    return once(true)
670  }
671}
672
673async function ccusageVersion($: any): Promise<string | null> {
674  try {
675    const res = await npx($, ['-y', 'ccusage@latest', '--version'])
676    return res.stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null
677  } catch {
678    return null
679  }
680}
681
682/** Le fuseau de la machine (par Node) : ccusage groupe les jours avec, le mod calcule « aujourd'hui » avec. */
683async function timezone($: any): Promise<{ offset: number; name: string | null }> {
684  try {
685    const res: Run = await $.process.run(['node', '-p', 'JSON.stringify([new Date().getTimezoneOffset(), Intl.DateTimeFormat().resolvedOptions().timeZone])'], { timeoutMs: 15_000 })
686    const [offset, name] = JSON.parse(res.stdout.trim()) as [unknown, unknown]
687    if (typeof offset === 'number') return { offset, name: typeof name === 'string' && /^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)*$/.test(name) ? name : null }
688  } catch {
689    // Pas de Node à part : le fuseau de l'environnement du mod.
690  }
691  return { offset: new Date().getTimezoneOffset(), name: null }
692}
693
694async function homeDir($: any): Promise<string | null> {
695  return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? null
696}
697
698/** Le dossier de Claude Code : `CLAUDE_CONFIG_DIR` (le premier s'il y en a plusieurs), sinon `~/.claude`. */
699async function claudeDir($: any): Promise<string | null> {
700  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
701  if (custom) return custom.split(',')[0]!.trim()
702  const home = await homeDir($)
703  return home ? `${home}/.claude` : null
704}
705
706async function readActivity($: any, dir: string | null) {
707  if (!dir) return null
708  try {
709    return activityOf(JSON.parse(await $.fs.read(`${dir}/stats-cache.json`)))
710  } catch {
711    return null
712  }
713}
714
715// Lit le début (256 Kio) des plus petites transcriptions de chaque projet et rend `{ clé: dossier }` :
716// le premier `"cwd"` qui donne la clé, à défaut le premier tout court (comme `cwdOf`).
717// Par Node : une transcription dépasse souvent les 4 Mio que `$.fs.read` accepte.
718const CWD_SCRIPT = String.raw`const fs=require('fs'),p=require('path');const [dir,...keys]=process.argv.slice(1);const out={};for(const k of keys){try{const d=p.join(dir,'projects',k);const fl=fs.readdirSync(d).filter(f=>f.endsWith('.jsonl')).map(f=>({f,s:fs.statSync(p.join(d,f)).size})).filter(x=>x.s>0).sort((a,b)=>a.s-b.s).slice(0,5);let first=null,hit=null;for(const {f} of fl){const fd=fs.openSync(p.join(d,f),'r');const b=Buffer.alloc(262144);const n=fs.readSync(fd,b,0,b.length,0);fs.closeSync(fd);for(const m of b.toString('utf8',0,n).matchAll(/"cwd":"((?:[^"\\]|\\.)*)"/g)){let c;try{c=JSON.parse('"'+m[1]+'"')}catch{continue}if(c.replace(/[^a-zA-Z0-9]/g,'-')===k){hit=c;break}if(first===null)first=c}if(hit!==null)break}const r=hit??first;if(r!==null)out[k]=r}catch{}}console.log(JSON.stringify(out))`
719
720/**
721 * Le dossier de chaque projet de ce poste, lu une fois dans ses transcriptions puis gardé : la clé
722 * ccusage seule ne dit pas où étaient les tirets. Un dossier gardé qui ne donne pas sa clé
723 * (`isStale`) est relu ; s'il n'est plus lisible, l'ancien reste. Seules les clés de ce poste
724 * sont rendues : les projets des autres postes reviennent de leurs fichiers publiés. Un seul
725 * processus Node pour tous les projets à lire ; s'il échoue, une lecture directe des petites
726 * transcriptions.
727 */
728async function resolveProjects($: any, keys: readonly string[], known: Record<string, ProjectInfo>, dir: string | null, home: string | null): Promise<Record<string, ProjectInfo>> {
729  const out: Record<string, ProjectInfo> = {}
730  for (const k of keys) if (known[k] && !isStale(k, known[k]!)) out[k] = known[k]!
731  const todo = keys.filter((k) => !k.startsWith('@') && !out[k]).slice(0, 120)
732  let found: Record<string, unknown> = {}
733  if (dir && todo.length) {
734    try {
735      const res: Run = await $.process.run(['node', '-e', CWD_SCRIPT, dir, ...todo], { timeoutMs: 60_000 })
736      if (res.exitCode === 0) found = JSON.parse(res.stdout.trim() || '{}')
737    } catch {
738      // Pas de Node à part : la lecture directe ci-dessous.
739    }
740  }
741  for (const key of todo) {
742    let path: string | null = typeof found[key] === 'string' ? (found[key] as string) : null
743    if (dir && !path) {
744      try {
745        const entries: { name: string; kind: string; size: number }[] = await $.fs.list(`${dir}/projects/${key}`)
746        const files = entries
747          .filter((x) => x.kind === 'file' && x.name.endsWith('.jsonl') && x.size > 0 && x.size < 3_500_000)
748          .sort((a, b) => a.size - b.size)
749          .slice(0, 5)
750        let first: string | null = null
751        for (const f of files) {
752          const cwd = cwdOf(await $.fs.read(`${dir}/projects/${key}/${f.name}`), key)
753          if (cwd && projectKey(cwd) === key) {
754            path = cwd
755            break
756          }
757          first ??= cwd
758        }
759        path ??= first
760      } catch {
761        // Dossier effacé ou illisible : le nom vient de la clé.
762      }
763    }
764    out[key] = path ? { name: nameOfPath(path, home), path } : (known[key] ?? { name: '', path: null })
765  }
766  // Au-delà des 120 lus par relevé : le dossier gardé, en attendant le relevé suivant.
767  for (const k of keys) if (!out[k] && known[k]) out[k] = known[k]!
768  return out
769}
770
771// ─── Petits outils ──────────────────────────────────────────────────────────
772
773const isSnapshot = (value: unknown): value is Snapshot =>
774  typeof value === 'object' && value !== null && (value as Snapshot).schema === SCHEMA && Array.isArray((value as Snapshot).rows) && typeof (value as Snapshot).fetchedAt === 'number' && Array.isArray((value as Snapshot).sessions)
775
776// Un mot dans la zone d'activité, doublé d'un toast ; les confirmations s'effacent seules,
777// les erreurs restent jusqu'au « x ».
778async function say($: any, tone: 'ok' | 'error' | 'info', text: string) {
779  await update($, notice, () => ({ text, tone }))
780  await quietly(() => $.ui.toast(text))
781  if (tone !== 'error') $.clock.after(8000, () => void update($, notice, (x) => (x && x.text === text ? null : x)))
782}
783
hooks/model.ts 986 lines
1// Données pures de stats-board : palette, lecture des sorties de ccusage et du cache de
2// /stats, fusion avec les usages des autres postes, filtres, agrégats et libellés en français.
3// Aucun appel au moteur ici : tout ce qui touche `$` vit dans register.tsx.
4
5import type { Activity, BlockRow, DiscussionEntry, Grain, Machine, Metric, Period, ProjectInfo, Published, SessionRow, Ui, UsageRow } from '../types'
6
7// Couleurs brutes (hex) mi-tons, copiées de git-board (hooks/model.ts) : lisibles en thème
8// clair comme sombre, sur le terminal comme sur le desktop.
9export const C = {
10  violet: '#9B7BEA',
11  lilac: '#B79BF5',
12  blue: '#4A9EE8',
13  cyan: '#2EB8C9',
14  teal: '#3FB68B',
15  tangerine: '#E8913A',
16  coral: '#EF7D57',
17  amber: '#D9A520',
18  green: '#4CB782',
19  red: '#E5534B',
20  pink: '#E06C9F',
21  magenta: '#C36BD9',
22  indigo: '#7C83F2',
23  sky: '#56B4E9',
24  sand: '#C9A66B',
25  slate: '#7F8EA3',
26  gray: '#8B8B93',
27  /** Texte foncé posé sur un aplat de couleur. */
28  ink: '#17171C',
29} as const
30
31// L'app desktop fond les espaces ordinaires : les écarts dans un texte sont insécables.
32// NB contient une espace insécable U+00A0 littérale (invisible) : ne pas la remplacer.
33export const NB = ' '
34export const SEP = `${NB}${NB}·${NB}${NB}`
35
36/** Couleur d'identité du mod (bleu ciel, comme les stats du desktop) ; l'ambre dit l'argent. */
37export const BRAND = C.sky
38export const MONEY = C.amber
39
40/** Format du relevé (`Snapshot.schema`) : 3 depuis que chaque ligne dit son poste. */
41export const SCHEMA = 3
42/** Format du fichier qu'un poste publie dans `claude-usage` (`Published.schema`). */
43export const PUBLISH_SCHEMA = 1
44/** Le nom de ce poste quand aucun n'est réglé : ses usages ne sont alors pas publiés. */
45export const LOCAL_LABEL = 'Ce poste'
46/** Les brouillons du desktop (`scratch-workspaces`), réunis en un seul projet. */
47export const DRAFTS = '@brouillons'
48/** Lignes que ccusage n'a rattachées à aucun projet. */
49export const UNKNOWN = '@inconnu'
50export const UNKNOWN_MODEL = '@modele-inconnu'
51
52export const DAY_MS = 86_400_000
53
54type Json = Record<string, any>
55
56const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
57const str = (v: unknown): string => (typeof v === 'string' ? v : '')
58const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
59export const unique = <T,>(list: readonly T[]): T[] => [...new Set(list)]
60
61// ─── Lecture des sorties de ccusage ─────────────────────────────────────────
62
63/** Le JSON d'une sortie de ccusage : ce qui précède la première accolade est ignoré. */
64export function parseJson(out: string): Json {
65  const start = out.indexOf('{')
66  const end = out.lastIndexOf('}')
67  if (start < 0 || end < start) throw new Error('sortie illisible (pas de JSON)')
68  return JSON.parse(out.slice(start, end + 1))
69}
70
71const isDate = (date: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(date)
72
73/** Les lignes d'un jour de ccusage, une par modèle. `date` en v20, `period` dans le rapport tous agents. */
74function rowsOfDay(day: Json, project: string, machine: string): UsageRow[] {
75  const date = str(day?.date) || str(day?.period)
76  if (!isDate(date)) return []
77  const parts: Json[] = Array.isArray(day.modelBreakdowns) ? day.modelBreakdowns : []
78  const rows: UsageRow[] = parts.length
79    ? parts.map((m) => ({
80        date,
81        project,
82        machine,
83        model: str(m.modelName) || UNKNOWN_MODEL,
84        input: num(m.inputTokens),
85        output: num(m.outputTokens),
86        cacheWrite: num(m.cacheCreationTokens),
87        cacheRead: num(m.cacheReadTokens),
88        cost: num(m.cost),
89      }))
90    : [
91        {
92          date,
93          project,
94          machine,
95          model: UNKNOWN_MODEL,
96          input: num(day.inputTokens),
97          output: num(day.outputTokens),
98          cacheWrite: num(day.cacheCreationTokens),
99          cacheRead: num(day.cacheReadTokens),
100          cost: num(day.totalCost),
101        },
102      ]
103  // `<synthetic>` : les messages que Claude Code fabrique lui-même, sans token.
104  return rows.filter((r) => r.model !== '<synthetic>' && r.input + r.output + r.cacheWrite + r.cacheRead + r.cost > 0)
105}
106
107/** `ccusage claude daily --json --instances` : `{ projects: { clé: jours[] }, totals }` ; sans projets, `{ daily }`. */
108export function rowsOfDaily(json: Json, machine: string): UsageRow[] {
109  if (json?.projects && typeof json.projects === 'object') {
110    return Object.entries(json.projects as Record<string, unknown>).flatMap(([key, days]) =>
111      Array.isArray(days) ? days.flatMap((d) => rowsOfDay(d, key === 'unknown' ? UNKNOWN : key, machine)) : [],
112    )
113  }
114  return (Array.isArray(json?.daily) ? json.daily : []).flatMap((d: Json) => rowsOfDay(d, UNKNOWN, machine))
115}
116
117/** `ccusage claude session --json` : `{ sessions: [...] }`. */
118export function sessionsOf(json: Json, machine: string): SessionRow[] {
119  const list: Json[] = Array.isArray(json?.sessions) ? json.sessions : Array.isArray(json?.session) ? json.session : []
120  const out: SessionRow[] = []
121  for (const s of list) {
122    const last = Date.parse(str(s.lastActivity) || str(s.metadata?.lastActivity))
123    const first = Date.parse(str(s.firstActivity))
124    const id = str(s.sessionId) || str(s.period)
125    if (!id || !Number.isFinite(last)) continue
126    // Un sous-agent de workflow écrit sous `<projet>\<session>\subagents\…` : il compte pour son
127    // projet et se rattache à la discussion qui l'a lancé.
128    const path = str(s.projectPath).split(/[\\/]/)
129    out.push({
130      id,
131      machine,
132      project: path[0] || UNKNOWN,
133      parent: path[2] === 'subagents' && path[1] ? path[1] : null,
134      first: Number.isFinite(first) ? first : last,
135      last,
136      cost: num(s.totalCost),
137      tokens: num(s.totalTokens),
138      output: num(s.outputTokens),
139      models: strings(s.modelsUsed).filter((m) => m !== '<synthetic>'),
140    })
141  }
142  return out
143}
144
145/** `ccusage claude blocks --json` : les blocs de 5 h, sans les creux (`isGap`). */
146export function blocksOf(json: Json): BlockRow[] {
147  const list: Json[] = Array.isArray(json?.blocks) ? json.blocks : []
148  const out: BlockRow[] = []
149  for (const b of list) {
150    if (b.isGap === true) continue
151    const start = Date.parse(str(b.startTime))
152    if (!Number.isFinite(start)) continue
153    const end = Date.parse(str(b.endTime))
154    const lastAt = Date.parse(str(b.actualEndTime))
155    const tc = b.tokenCounts ?? {}
156    const tokens = num(b.totalTokens) || num(tc.inputTokens) + num(tc.outputTokens) + num(tc.cacheCreationInputTokens) + num(tc.cacheReadInputTokens)
157    out.push({
158      start,
159      end: Number.isFinite(end) ? end : start + 5 * 3_600_000,
160      lastAt: Number.isFinite(lastAt) ? lastAt : start,
161      isActive: b.isActive === true,
162      cost: num(b.costUSD),
163      tokens,
164      models: strings(b.models).filter((m) => m !== '<synthetic>'),
165      costPerHour: b.burnRate ? num(b.burnRate.costPerHour) : null,
166      projectedCost: b.projection ? num(b.projection.totalCost) : null,
167      remainingMinutes: b.projection ? num(b.projection.remainingMinutes) : null,
168    })
169  }
170  return out
171}
172
173/** Les modèles sans prix : `totals.unpricedModels` et les `missingPricing` du détail par modèle. */
174export function unpricedOf(...jsons: (Json | null)[]): string[] {
175  const found = new Set<string>()
176  const scanDays = (days: unknown) => {
177    if (!Array.isArray(days)) return
178    for (const d of days) for (const m of Array.isArray(d?.modelBreakdowns) ? d.modelBreakdowns : []) if (m?.missingPricing === true && typeof m.modelName === 'string') found.add(m.modelName)
179  }
180  for (const json of jsons) {
181    if (!json) continue
182    strings(json.totals?.unpricedModels).forEach((m) => found.add(m))
183    scanDays(json.daily)
184    scanDays(json.sessions)
185    if (json.projects && typeof json.projects === 'object') Object.values(json.projects as Record<string, unknown>).forEach(scanDays)
186  }
187  found.delete('<synthetic>')
188  return [...found].sort()
189}
190
191/** Le cache de /stats de Claude Code (`~/.claude/stats-cache.json`). */
192export function activityOf(json: Json): Activity | null {
193  if (!json || typeof json !== 'object' || !Array.isArray(json.dailyActivity)) return null
194  const days = (json.dailyActivity as Json[])
195    .map((d) => ({ date: str(d.date), messages: num(d.messageCount), sessions: num(d.sessionCount), tools: num(d.toolCallCount) }))
196    .filter((d) => isDate(d.date))
197  const hours = Array.from({ length: 24 }, (_, h) => num(json.hourCounts?.[String(h)]))
198  const l = json.longestSession
199  return {
200    through: str(json.lastComputedDate) || null,
201    days,
202    hours,
203    sessions: num(json.totalSessions),
204    messages: num(json.totalMessages),
205    longest: l && typeof l === 'object' ? { ms: num(l.duration), messages: num(l.messageCount), at: str(l.timestamp) } : null,
206    since: str(json.firstSessionDate) || null,
207  }
208}
209
210/**
211 * Garde les jours que Claude Code a effacés (ses journaux ne vivent que `cleanupPeriodDays`
212 * jours) : les lignes de ce poste dans le relevé précédent plus anciennes que le premier jour
213 * du nouveau. Celles des autres postes reviennent par leurs fichiers publiés.
214 */
215export function keepHistory(fresh: UsageRow[], previous: readonly UsageRow[] | undefined, machine: string): { rows: UsageRow[]; kept: number } {
216  const mine = (previous ?? []).filter((r) => r.machine === machine)
217  if (!mine.length) return { rows: fresh, kept: 0 }
218  // Rien de frais (poste resté inactif plus longtemps que les journaux) : tout l'historique reste.
219  const first = fresh.length ? fresh.reduce((m, r) => (r.date < m ? r.date : m), fresh[0]!.date) : '9999-12-31'
220  const old = mine.filter((r) => r.date < first)
221  return { rows: [...old, ...fresh], kept: unique(old.map((r) => r.date)).length }
222}
223
224/** Les sessions de ce poste dans le relevé précédent que le nouveau n'a plus, parce que leurs journaux ont été effacés. */
225export function keepSessions(fresh: SessionRow[], previous: readonly SessionRow[] | undefined, machine: string): SessionRow[] {
226  const mine = (previous ?? []).filter((s) => s.machine === machine)
227  if (!mine.length) return fresh
228  const ids = new Set(fresh.map((s) => s.id))
229  const first = fresh.length ? Math.min(...fresh.map((s) => s.first)) : Infinity
230  return [...mine.filter((s) => !ids.has(s.id) && s.last < first), ...fresh]
231}
232
233/**
234 * L'historique connu de ce poste, sous son nom actuel, pour `keepHistory` et `keepSessions` :
235 * le relevé gardé (ses lignes à ce nom ou encore « Ce poste »), un relevé gardé de l'ancien
236 * format (schéma 2 : tout sauf l'ancien PC) et le fichier que ce poste a déjà publié. Ainsi ni
237 * une mise à jour du mod, ni un premier nommage, ni un nouveau clone ne perdent les jours que
238 * Claude Code a effacés. Une ligne présente deux fois (même jour, projet et modèle) compte une fois.
239 */
240export function ownHistory(machine: string, sources: { previous?: { rows: readonly UsageRow[]; sessions: readonly SessionRow[] } | null; legacy?: Json | null; published?: Published | null }): { rows: UsageRow[]; sessions: SessionRow[] } {
241  const rows = new Map<string, UsageRow>()
242  const sessions = new Map<string, SessionRow>()
243  const addRow = (r: UsageRow) => {
244    const key = `${r.date}|${r.project}|${r.model}`
245    if (!rows.has(key)) rows.set(key, { ...r, machine })
246  }
247  const addSession = (s: SessionRow) => {
248    if (!sessions.has(s.id)) sessions.set(s.id, { ...s, machine })
249  }
250  const isMine = (m: string) => m === machine || m === LOCAL_LABEL
251  for (const r of sources.previous?.rows ?? []) if (isMine(r.machine)) addRow(r)
252  for (const s of sources.previous?.sessions ?? []) if (isMine(s.machine)) addSession(s)
253  const legacy = sources.legacy
254  if (legacy && legacy.schema === 2 && Array.isArray(legacy.rows)) {
255    for (const r of legacy.rows as Json[]) if (r && !r.isPast && isDate(str(r.date)) && typeof r.project === 'string') addRow({ date: r.date, project: r.project, machine, model: str(r.model) || UNKNOWN_MODEL, input: num(r.input), output: num(r.output), cacheWrite: num(r.cacheWrite), cacheRead: num(r.cacheRead), cost: num(r.cost) })
256    for (const s of Array.isArray(legacy.sessions) ? (legacy.sessions as Json[]) : []) if (s && typeof s.id === 'string' && Number.isFinite(s.last)) addSession({ id: s.id, machine, project: str(s.project) || UNKNOWN, parent: typeof s.parent === 'string' ? s.parent : null, first: num(s.first) || num(s.last), last: num(s.last), cost: num(s.cost), tokens: num(s.tokens), output: num(s.output), models: strings(s.models) })
257  }
258  for (const r of sources.published?.rows ?? []) addRow(r)
259  for (const s of sources.published?.sessions ?? []) addSession(s)
260  return { rows: [...rows.values()], sessions: [...sessions.values()] }
261}
262
263// ─── Postes ─────────────────────────────────────────────────────────────────
264
265/** Le fichier d'un poste dans le dépôt partagé : `Mac.json`, `Portable.json`… */
266export const machineFile = (machine: string): string => `${machine.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
267
268/** Ce que ce poste publie : ses lignes, ses sessions, et les projets qu'elles citent (sans `updatedAt`). */
269export function publishedOf(o: { machine: string; tzOffset: number; rows: readonly UsageRow[]; sessions: readonly SessionRow[]; projects: Record<string, ProjectInfo> }): Omit<Published, 'updatedAt'> {
270  const keys = new Set([...o.rows.map((r) => r.project), ...o.sessions.map((s) => s.project)])
271  const projects: Record<string, ProjectInfo> = {}
272  for (const k of [...keys].sort()) if (o.projects[k]) projects[k] = { name: o.projects[k]!.name, path: o.projects[k]!.path }
273  return { schema: PUBLISH_SCHEMA, machine: o.machine, tzOffset: o.tzOffset, rows: [...o.rows], sessions: [...o.sessions], projects }
274}
275
276/** Le fichier publié d'un autre poste, ou null s'il n'a pas la bonne forme. Ses lignes prennent son nom. */
277export function parsePublished(json: Json): Published | null {
278  if (!json || json.schema !== PUBLISH_SCHEMA || typeof json.machine !== 'string' || !json.machine || !Array.isArray(json.rows)) return null
279  const machine: string = json.machine
280  const rows = (json.rows as Json[])
281    .filter((r) => r && isDate(str(r.date)) && typeof r.project === 'string' && typeof r.model === 'string')
282    .map((r): UsageRow => ({ date: r.date, project: r.project, machine, model: r.model, input: num(r.input), output: num(r.output), cacheWrite: num(r.cacheWrite), cacheRead: num(r.cacheRead), cost: num(r.cost) }))
283  const sessions = (Array.isArray(json.sessions) ? (json.sessions as Json[]) : [])
284    .filter((s) => s && typeof s.id === 'string' && typeof s.project === 'string' && Number.isFinite(s.last))
285    .map((s): SessionRow => ({ id: s.id, machine, project: s.project, parent: typeof s.parent === 'string' ? s.parent : null, first: num(s.first) || num(s.last), last: num(s.last), cost: num(s.cost), tokens: num(s.tokens), output: num(s.output), models: strings(s.models) }))
286  const projects: Record<string, ProjectInfo> = {}
287  for (const [k, v] of Object.entries((json.projects ?? {}) as Record<string, Json>)) {
288    if (v && typeof v === 'object') projects[k] = { name: str(v.name), path: typeof v.path === 'string' ? v.path : null }
289  }
290  return { schema: PUBLISH_SCHEMA, machine, updatedAt: num(json.updatedAt), tzOffset: num(json.tzOffset), rows, sessions, projects }
291}
292
293/** Le dossier d'une session lancée par SSH depuis le desktop : `ssh-<id de la session>`. */
294const SSH_KEY = /^ssh-(.+)$/
295
296/**
297 * Ajoute aux usages de ce poste ceux des autres. Un projet d'un autre poste rejoint celui qui porte
298 * le même nom de dossier (`C:\dev\PolePredict` et `~/dev/PolePredict`) : il prend la clé déjà connue,
299 * celle de ce poste d'abord ; un worktree compte sous le nom de son dépôt. Les brouillons du desktop
300 * gardent leur clé : ils se regroupent seuls. Une session SSH du desktop, que le poste qui l'a
301 * lancée garde sous `ssh-<id>`, ne compte qu'une fois si le poste où elle a tourné la publie.
302 */
303export function mergeMachines(
304  local: { machine: string; rows: readonly UsageRow[]; sessions: readonly SessionRow[]; projects: Record<string, ProjectInfo> },
305  others: readonly Published[],
306): { rows: UsageRow[]; sessions: SessionRow[]; projects: Record<string, ProjectInfo>; machines: string[]; sessionless: string[] } {
307  const projects: Record<string, ProjectInfo> = { ...local.projects }
308  const sessionless: string[] = []
309  const byName = new Map<string, string>()
310  const nameOf = (key: string, info: ProjectInfo | undefined): string | null => {
311    const name = info?.name?.toLowerCase()
312    return name && !key.startsWith('@') && !isDraft(key, info) ? name : null
313  }
314  // Les sessions qui ont tourné sur chaque poste : une copie `ssh-<id>` d'une session d'un autre poste est écartée.
315  const ranOn = new Map<string, string>()
316  for (const p of [{ machine: local.machine, sessions: local.sessions }, ...others]) for (const x of p.sessions) if (!SSH_KEY.test(x.project)) ranOn.set(x.id, p.machine)
317  const isCopy = (project: string, machine: string): boolean => {
318    const host = ranOn.get(project.match(SSH_KEY)?.[1] ?? '')
319    return !!host && host !== machine
320  }
321  const ownRows = local.rows.filter((r) => !isCopy(r.project, local.machine))
322  const ownSessions = local.sessions.filter((x) => !isCopy(x.project, local.machine))
323  for (const key of unique([...ownRows.map((r) => r.project), ...ownSessions.map((s) => s.project)])) {
324    const root = rootOf(key, local.projects)
325    if (root.info && !projects[root.key]) projects[root.key] = root.info
326    const name = nameOf(root.key, root.info)
327    if (name && !byName.has(name)) byName.set(name, root.key)
328  }
329  const rows = [...ownRows]
330  const sessions = [...ownSessions]
331  const machines = [local.machine]
332  for (const o of others) {
333    if (o.machine === local.machine) continue
334    if (!machines.includes(o.machine)) machines.push(o.machine)
335    if (!o.sessions.length && !sessionless.includes(o.machine)) sessionless.push(o.machine)
336    const keyOf = new Map<string, string>()
337    const target = (key: string): string => {
338      const known = keyOf.get(key)
339      if (known) return known
340      const root = rootOf(key, o.projects)
341      const name = nameOf(root.key, root.info)
342      const out = (name && byName.get(name)) || root.key
343      if (out === root.key) {
344        if (root.info && !projects[root.key]) projects[root.key] = root.info
345        if (name && !byName.has(name)) byName.set(name, root.key)
346      }
347      keyOf.set(key, out)
348      return out
349    }
350    for (const r of o.rows) if (!isCopy(r.project, o.machine)) rows.push({ ...r, project: target(r.project) })
351    for (const s of o.sessions) if (!isCopy(s.project, o.machine)) sessions.push({ ...s, project: target(s.project) })
352  }
353  return { rows, sessions: sessions.sort((a, b) => a.last - b.last), projects, machines, sessionless }
354}
355
356// ─── Projets ────────────────────────────────────────────────────────────────
357
358/** La clé ccusage d'un dossier : chaque caractère hors lettres et chiffres devient « - ». */
359export const projectKey = (path: string): string => path.replace(/[^a-zA-Z0-9]/g, '-')
360
361/**
362 * Le dossier d'une transcription : le premier `"cwd":"…"` qui donne la clé `key`, à défaut le
363 * premier tout court. Une session commencée ailleurs (un brouillon du desktop rangé ensuite dans
364 * un projet) cite d'abord son dossier de départ.
365 */
366export function cwdOf(text: string, key?: string): string | null {
367  let first: string | null = null
368  for (const m of text.matchAll(/"cwd":"((?:[^"\\]|\\.)*)"/g)) {
369    let path: string
370    try {
371      path = JSON.parse(`"${m[1]}"`) as string
372    } catch {
373      continue
374    }
375    if (!key || projectKey(path) === key) return path
376    first ??= path
377  }
378  return first
379}
380
381const segments = (path: string): string[] => path.split(/[\\/]+/).filter(Boolean)
382
383const WORKTREE_KEY = /--claude-worktrees-.+$/
384const WORKTREE_PATH = /[\\/]\.claude[\\/]worktrees[\\/].*$/
385
386/** Le projet d'un worktree de Claude (`<dépôt>/.claude/worktrees/<nom>`) : celui de son dépôt. */
387export const baseKey = (key: string): string => key.replace(WORKTREE_KEY, '') || key
388
389/**
390 * Un dossier gardé qui ne donne pas sa clé, lu avant que `cwdOf` ne préfère le bon : à relire.
391 * Un worktree lu à son dépôt (session partie du dépôt) compte pour le même projet. Seulement
392 * pour une clé qui est un chemin (pas `ssh-…`) et que Claude Code n'a pas tronquée.
393 */
394export const isStale = (key: string, info: ProjectInfo): boolean =>
395  /^(-|[A-Za-z]--)/.test(key) && key.length <= 200 && !!info.path && baseKey(projectKey(info.path)) !== baseKey(key)
396
397/** La clé et le dossier du projet d'une clé : pour un worktree, ceux du dépôt, déduits du worktree s'ils manquent. */
398function rootOf(key: string, projects: Record<string, ProjectInfo>): { key: string; info: ProjectInfo | undefined } {
399  const base = baseKey(key)
400  if (base === key || projects[base]) return { key: base, info: projects[base] }
401  const path = projects[key]?.path?.replace(WORKTREE_PATH, '')
402  return { key: base, info: path && projectKey(path) === base ? { name: segments(path).pop() ?? '', path } : undefined }
403}
404
405/** Le nom court d'un dossier : son dernier segment, `~` pour le dossier personnel. */
406export function nameOfPath(path: string, home: string | null): string {
407  if (home && segments(path).join('/').toLowerCase() === segments(home).join('/').toLowerCase()) return '~'
408  return segments(path).pop() ?? path
409}
410
411export const isDraft = (key: string, info?: ProjectInfo): boolean => /Claude-scratch-workspaces/i.test(key) || /[\\/]scratch-workspaces[\\/]/i.test(info?.path ?? '')
412
413/** Le projet affiché d'une clé : les brouillons du desktop sont réunis en un seul, un worktree rejoint son dépôt. */
414export const groupOf = (key: string, projects: Record<string, ProjectInfo>): string => (key.startsWith('@') ? key : isDraft(key, projects[key]) ? DRAFTS : baseKey(key))
415
416/** Longueur maximale d'un nom d'affichage de `names.json`. */
417const LABEL_MAX = 60
418
419/**
420 * Les noms d'affichage de `names.json` (dépôt partagé) : `{ "<nom de dossier>": "<nom affiché>" }`. Le nom de
421 * dossier se compare sans la casse, comme au rapprochement des postes ; une entrée vide ou qui n'est pas du texte
422 * est ignorée. `null` si le fichier n'est pas un objet.
423 */
424export function parseLabels(json: unknown): Record<string, string> | null {
425  if (!json || typeof json !== 'object' || Array.isArray(json)) return null
426  const out: Record<string, string> = {}
427  for (const [k, v] of Object.entries(json as Record<string, unknown>)) {
428    const folder = k.trim().toLowerCase()
429    const label = typeof v === 'string' ? v.trim().slice(0, LABEL_MAX) : ''
430    if (folder && label) out[folder] = label
431  }
432  return out
433}
434
435/**
436 * Les projets avec leur nom d'affichage : celui de `names.json` pour le dernier segment de leur dossier (pas « ~ »
437 * pour le dossier personnel, comme le récap), à défaut leur nom, s'il y en a un.
438 */
439export function withLabels(projects: Record<string, ProjectInfo>, labels: Record<string, string>): Record<string, ProjectInfo> {
440  const out: Record<string, ProjectInfo> = {}
441  for (const [k, info] of Object.entries(projects)) {
442    const folder = (info.path ? segments(info.path).pop() : null) ?? info.name
443    const label = folder ? labels[folder.toLowerCase()] : undefined
444    out[k] = label ? { ...info, label } : { name: info.name, path: info.path }
445  }
446  return out
447}
448
449/** Le nom d'un projet affiché ; à défaut de dossier connu, la clé sans le préfixe du dossier personnel. */
450export function projectLabel(key: string, projects: Record<string, ProjectInfo>, homeKey: string | null): string {
451  if (key === DRAFTS) return 'Brouillons du desktop'
452  if (key === UNKNOWN) return 'Sans projet'
453  const info = projects[key]
454  if (info?.name) return info.label || info.name
455  if (homeKey && key === homeKey) return '~'
456  // Sans dossier connu : sans le lecteur ni le dossier personnel ; un worktree de Claude par son nom ;
457  // « -- » est un séparateur suivi d'un point (`\.claude`).
458  const rest = homeKey && key.startsWith(`${homeKey}-`) ? key.slice(homeKey.length + 1) : key.replace(/^[A-Za-z]--/, '')
459  const worktree = rest.match(/--claude-worktrees-(.+)$/)
460  if (worktree) return worktree[1]!
461  return rest.replace(/^-/, '.').replace(/--/g, '/.') || key
462}
463
464/** Les noms affichés de plusieurs projets ; deux noms égaux prennent leur dossier parent. */
465export function projectLabels(keys: readonly string[], projects: Record<string, ProjectInfo>, homeKey: string | null): Map<string, string> {
466  const base = new Map(keys.map((k) => [k, projectLabel(k, projects, homeKey)] as const))
467  const count = new Map<string, number>()
468  base.forEach((v) => count.set(v, (count.get(v) ?? 0) + 1))
469  const out = new Map<string, string>()
470  for (const [k, v] of base) {
471    const parent = projects[k]?.path ? segments(projects[k]!.path!).slice(-2, -1)[0] : undefined
472    out.set(k, (count.get(v) ?? 0) > 1 && parent ? `${parent}/${v}` : v)
473  }
474  return out
475}
476
477// ─── Modèles ────────────────────────────────────────────────────────────────
478
479type ModelId = { family: string; version: number[] }
480
481/** `claude-opus-5-5` → opus 5.5 ; `claude-haiku-4-5-20251001` → haiku 4.5 ; `claude-3-5-sonnet-…` → sonnet 3.5. */
482function parseModel(id: string): ModelId | null {
483  const a = id.match(/^claude-([a-z]+)-([\d-]+?)(?:-\d{8})?$/)
484  if (a) return { family: a[1]!, version: a[2]!.split('-').map(Number) }
485  const b = id.match(/^claude-([\d-]+?)-([a-z]+)(?:-\d{8})?$/)
486  if (b) return { family: b[2]!, version: b[1]!.split('-').map(Number) }
487  return null
488}
489
490const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
491
492export function modelName(id: string): string {
493  if (id === UNKNOWN_MODEL) return 'Modèle inconnu'
494  const m = parseModel(id)
495  return m ? `${cap(m.family)}${NB}${m.version.join('.')}` : id
496}
497
498// Teintes ordonnées pour que deux voisines se distinguent : une famille compte trop de
499// versions (sept Opus) pour des nuances d'une même teinte.
500const MODEL_PALETTE = [C.violet, C.sky, C.amber, C.teal, C.coral, C.pink, C.indigo, C.green, C.magenta, C.cyan, C.tangerine, C.sand, C.blue, C.lilac, C.slate, C.red]
501
502/** Une couleur par modèle, dans l'ordre donné (les plus utilisés d'abord) : les plus utilisés ont les plus distinctes. */
503export function modelColors(ranked: readonly string[]): Record<string, string> {
504  const out: Record<string, string> = {}
505  unique(ranked).forEach((id, i) => (out[id] = MODEL_PALETTE[i % MODEL_PALETTE.length]!))
506  return out
507}
508
509// ─── Dates ──────────────────────────────────────────────────────────────────
510
511/** Le jour local d'un instant, d'après le décalage horaire relevé. */
512export const dayOf = (ms: number, tzOffset: number): string => new Date(ms - tzOffset * 60_000).toISOString().slice(0, 10)
513const utc = (date: string): number => Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)))
514export const addDays = (date: string, n: number): string => new Date(utc(date) + n * DAY_MS).toISOString().slice(0, 10)
515export const daysBetween = (a: string, b: string): number => Math.round((utc(b) - utc(a)) / DAY_MS)
516/** Jour de la semaine, 0 pour lundi. */
517export const weekdayOf = (date: string): number => (new Date(utc(date)).getUTCDay() + 6) % 7
518export const weekOf = (date: string): string => addDays(date, -weekdayOf(date))
519export const monthOf = (date: string): string => date.slice(0, 7)
520const nextMonth = (month: string): string => {
521  const y = Number(month.slice(0, 4))
522  const m = Number(month.slice(5, 7))
523  return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
524}
525export const daysInMonth = (month: string): number => daysBetween(`${month}-01`, `${nextMonth(month)}-01`)
526
527const MONTHS = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']
528const MONTHS_LONG = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
529const WEEKDAYS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']
530export const WEEKDAY_LONG = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
531export const DAY_LETTERS = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
532
533export const monthShort = (date: string): string => MONTHS[Number(date.slice(5, 7)) - 1] ?? date.slice(5, 7)
534/** « 7 oct. », avec l'année si `withYear`. */
535export const fmtDate = (date: string, withYear = false): string =>
536  `${Number(date.slice(8, 10))}${NB}${monthShort(date)}${withYear ? `${NB}${date.slice(0, 4)}` : ''}`
537/** « mar. 7 oct. » */
538export const fmtDay = (date: string): string => `${WEEKDAYS[weekdayOf(date)]}${NB}${fmtDate(date)}`
539/** « octobre 2026 » pour `2026-10`. */
540export const fmtMonth = (month: string): string => `${MONTHS_LONG[Number(month.slice(5, 7)) - 1] ?? month}${NB}${month.slice(0, 4)}`
541export const fmtMonthShort = (month: string): string => `${MONTHS[Number(month.slice(5, 7)) - 1] ?? month}`
542
543/** « à l'instant », « il y a 3 min », « il y a 2 h », « il y a 4 j ». */
544export function ago(ms: number, now: number): string {
545  const s = Math.max(0, Math.round((now - ms) / 1000))
546  if (s < 45) return "à l'instant"
547  if (s < 3600) return `il${NB}y${NB}a${NB}${Math.max(1, Math.round(s / 60))}${NB}min`
548  if (s < 86_400) return `il${NB}y${NB}a${NB}${Math.round(s / 3600)}${NB}h`
549  return `il${NB}y${NB}a${NB}${Math.round(s / 86_400)}${NB}j`
550}
551
552/** « 45 min », « 2 h 05 », « 19 j 23 h ». */
553export function fmtDuration(ms: number): string {
554  const min = Math.round(ms / 60_000)
555  if (min < 1) return `<${NB}1${NB}min`
556  if (min < 60) return `${min}${NB}min`
557  const h = Math.floor(min / 60)
558  if (h < 24) return `${h}${NB}h${NB}${String(min % 60).padStart(2, '0')}`
559  return `${Math.floor(h / 24)}${NB}j${NB}${h % 24}${NB}h`
560}
561
562// ─── Nombres ────────────────────────────────────────────────────────────────
563
564/** Un entier groupé par milliers (espaces insécables) : « 120 394 ». */
565export function fmtInt(n: number): string {
566  const s = String(Math.round(Math.abs(n))).replace(/\B(?=(\d{3})+(?!\d))/g, NB)
567  return n < 0 ? `−${s}` : s
568}
569
570const dec = (n: number, digits: number): string => n.toFixed(digits).replace('.', ',').replace(/,0+$/, '')
571
572/** « 999 », « 85,4 k », « 46,5 M », « 21,9 Md ». */
573export function fmtTokens(n: number): string {
574  const a = Math.abs(n)
575  const unit = a >= 1e9 ? ([1e9, 'Md'] as const) : a >= 1e6 ? ([1e6, 'M'] as const) : a >= 1e3 ? ([1e3, 'k'] as const) : null
576  if (!unit) return String(Math.round(n))
577  const v = n / unit[0]
578  return `${dec(v, Math.abs(v) < 100 ? 1 : 0)}${NB}${unit[1]}`
579}
580
581/** « 4,56 $ », « 9 977 $ ». */
582export function fmtCost(n: number): string {
583  const a = Math.abs(n)
584  if (a === 0) return `0${NB}$`
585  if (a < 0.01) return `<${NB}0,01${NB}$`
586  if (a < 100) return `${n.toFixed(2).replace('.', ',')}${NB}$`
587  return `${fmtInt(n)}${NB}$`
588}
589
590/** Pour les axes et le bandeau : « 47 $ », « 1,2 k$ ». */
591export function fmtCostShort(n: number): string {
592  const a = Math.abs(n)
593  if (a >= 1e3) return `${dec(n / 1e3, a < 1e4 ? 1 : 0)}${NB}k$`
594  if (a >= 10) return `${Math.round(n)}${NB}$`
595  return `${dec(n, 1)}${NB}$`
596}
597
598export const fmtMetric = (n: number, metric: Metric, short = false): string => (metric === 'cost' ? (short ? fmtCostShort(n) : fmtCost(n)) : fmtTokens(n))
599
600export function fmtPct(ratio: number): string {
601  const p = ratio * 100
602  if (p > 0 && p < 1) return `<${NB}1${NB}%`
603  return `${dec(p, p < 10 && p % 1 !== 0 ? 1 : 0)}${NB}%`
604}
605
606/** Une variation signée : « +40 % », « −12 % ». */
607export function fmtDelta(ratio: number): string {
608  const p = Math.round(ratio * 100)
609  return `${p > 0 ? '+' : p < 0 ? '−' : '±'}${Math.abs(p)}${NB}%`
610}
611
612export const plural = (n: number, one: string, many = `${one}s`): string => `${fmtInt(n)}${NB}${n > 1 ? many : one}`
613
614// ─── Filtres et agrégats ────────────────────────────────────────────────────
615
616export type Totals = { input: number; output: number; cacheWrite: number; cacheRead: number; tokens: number; cost: number }
617export const zero = (): Totals => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, tokens: 0, cost: 0 })
618
619const add = (t: Totals, r: UsageRow): Totals => {
620  t.input += r.input
621  t.output += r.output
622  t.cacheWrite += r.cacheWrite
623  t.cacheRead += r.cacheRead
624  t.tokens += r.input + r.output + r.cacheWrite + r.cacheRead
625  t.cost += r.cost
626  return t
627}
628export const sum = (rows: readonly UsageRow[]): Totals => rows.reduce(add, zero())
629export const valueOf = (t: Totals, metric: Metric): number => (metric === 'cost' ? t.cost : t.tokens)
630/** Part du cache lu dans tout ce que le modèle a lu : entrée, cache écrit et cache lu. */
631export const cacheRatio = (t: Totals): number => (t.input + t.cacheWrite + t.cacheRead > 0 ? t.cacheRead / (t.input + t.cacheWrite + t.cacheRead) : 0)
632
633export const PERIOD_DAYS: Record<Exclude<Period, 'all'>, number> = { '90': 90, '30': 30, '7': 7 }
634
635export type Filter = { from: string | null; to: string; machine: Machine; project: string | null; model: string | null }
636
637export const filterOf = (ui: Ui, today: string): Filter => ({
638  from: ui.period === 'all' ? null : addDays(today, -(PERIOD_DAYS[ui.period] - 1)),
639  to: today,
640  machine: ui.machine,
641  project: ui.project,
642  model: ui.model,
643})
644
645/** La même fenêtre, juste avant : de quoi comparer une période ; null pour « Tout ». */
646export function previousOf(f: Filter): Filter | null {
647  if (!f.from) return null
648  const n = daysBetween(f.from, f.to) + 1
649  return { ...f, from: addDays(f.from, -n), to: addDays(f.from, -1) }
650}
651
652export function keepRow(r: UsageRow, f: Filter, projects: Record<string, ProjectInfo>): boolean {
653  if (f.from && r.date < f.from) return false
654  if (r.date > f.to) return false
655  if (f.machine !== 'all' && r.machine !== f.machine) return false
656  if (f.project && groupOf(r.project, projects) !== f.project) return false
657  if (f.model && r.model !== f.model) return false
658  return true
659}
660
661export const selectRows = (rows: readonly UsageRow[], f: Filter, projects: Record<string, ProjectInfo>): UsageRow[] => rows.filter((r) => keepRow(r, f, projects))
662
663/** Les sessions de la période et des filtres. */
664export function selectSessions(sessions: readonly SessionRow[], f: Filter, projects: Record<string, ProjectInfo>, tzOffset: number): SessionRow[] {
665  return sessions.filter((s) => {
666    const day = dayOf(s.last, tzOffset)
667    if (f.machine !== 'all' && s.machine !== f.machine) return false
668    if (f.from && day < f.from) return false
669    if (f.project && groupOf(s.project, projects) !== f.project) return false
670    if (f.model && !s.models.includes(f.model)) return false
671    return true
672  })
673}
674
675export type Bucket = { key: string; total: Totals; byModel: Map<string, Totals> }
676
677const bucketKey = (date: string, grain: Grain): string => (grain === 'day' ? date : grain === 'week' ? weekOf(date) : monthOf(date))
678const nextKey = (key: string, grain: Grain): string => (grain === 'day' ? addDays(key, 1) : grain === 'week' ? addDays(key, 7) : nextMonth(key))
679
680/** Des seaux continus de `from` à `to` (jours, semaines du lundi ou mois), vides compris. */
681export function seriesOf(rows: readonly UsageRow[], grain: Grain, from: string, to: string): Bucket[] {
682  const map = new Map<string, Bucket>()
683  const last = bucketKey(to, grain)
684  for (let key = bucketKey(from, grain), i = 0; key <= last && i < 2000; key = nextKey(key, grain), i++) map.set(key, { key, total: zero(), byModel: new Map() })
685  for (const r of rows) {
686    const b = map.get(bucketKey(r.date, grain))
687    if (!b) continue
688    add(b.total, r)
689    const m = b.byModel.get(r.model) ?? zero()
690    b.byModel.set(r.model, add(m, r))
691  }
692  return [...map.values()]
693}
694
695/** Le grain le plus fin qui tient dans `slots` colonnes ou barres. */
696export function fitGrain(grain: Grain, from: string, to: string, slots: number): Grain {
697  const span = daysBetween(from, to) + 1
698  if (grain === 'day' && span <= slots) return 'day'
699  if (grain !== 'month' && Math.ceil(span / 7) + 1 <= slots) return 'week'
700  return 'month'
701}
702
703export const firstDate = (rows: readonly UsageRow[]): string | null => rows.reduce<string | null>((m, r) => (m === null || r.date < m ? r.date : m), null)
704
705export type ModelStat = { model: string; total: Totals; share: number }
706
707export function modelsOf(rows: readonly UsageRow[], metric: Metric): ModelStat[] {
708  const map = new Map<string, Totals>()
709  for (const r of rows) map.set(r.model, add(map.get(r.model) ?? zero(), r))
710  const all = [...map.values()].reduce((n, t) => n + valueOf(t, metric), 0)
711  return [...map.entries()]
712    .map(([model, total]) => ({ model, total, share: all > 0 ? valueOf(total, metric) / all : 0 }))
713    .sort((a, b) => valueOf(b.total, metric) - valueOf(a.total, metric) || b.total.tokens - a.total.tokens)
714}
715
716/** Un projet affiché : son total, sa part par poste, ses sessions. */
717export type ProjectStat = { key: string; total: Totals; byMachine: Record<string, Totals>; share: number; sessions: number; first: string; last: string; members: number }
718
719export function projectsOf(rows: readonly UsageRow[], sessions: readonly SessionRow[], projects: Record<string, ProjectInfo>, metric: Metric): ProjectStat[] {
720  const map = new Map<string, { total: Totals; byMachine: Record<string, Totals>; first: string; last: string; members: Set<string> }>()
721  for (const r of rows) {
722    const key = groupOf(r.project, projects)
723    const x = map.get(key) ?? { total: zero(), byMachine: {}, first: r.date, last: r.date, members: new Set<string>() }
724    add(x.total, r)
725    x.byMachine[r.machine] = add(x.byMachine[r.machine] ?? zero(), r)
726    if (r.date > x.last) x.last = r.date
727    if (r.date < x.first) x.first = r.date
728    x.members.add(r.project)
729    map.set(key, x)
730  }
731  const counts = new Map<string, number>()
732  for (const s of sessions) {
733    const key = groupOf(s.project, projects)
734    counts.set(key, (counts.get(key) ?? 0) + 1)
735  }
736  const all = [...map.values()].reduce((n, x) => n + valueOf(x.total, metric), 0)
737  return [...map.entries()]
738    .map(([key, x]) => ({ key, total: x.total, byMachine: x.byMachine, share: all > 0 ? valueOf(x.total, metric) / all : 0, sessions: counts.get(key) ?? 0, first: x.first, last: x.last, members: x.members.size }))
739    .sort((a, b) => valueOf(b.total, metric) - valueOf(a.total, metric))
740}
741
742/** Les jours actifs, triés. */
743export function activeDates(rows: readonly UsageRow[]): string[] {
744  return unique(rows.filter((r) => r.input + r.output + r.cacheWrite + r.cacheRead > 0).map((r) => r.date)).sort()
745}
746
747/** La plus longue série de jours actifs et la série en cours (qui finit aujourd'hui ou hier). */
748export function streaksOf(dates: readonly string[], today: string): { longest: number; longestEnd: string | null; current: number } {
749  let longest = 0
750  let longestEnd: string | null = null
751  let run = 0
752  let prev: string | null = null
753  for (const d of dates) {
754    run = prev !== null && daysBetween(prev, d) === 1 ? run + 1 : 1
755    if (run > longest) {
756      longest = run
757      longestEnd = d
758    }
759    prev = d
760  }
761  const set = new Set(dates)
762  let current = 0
763  let day = set.has(today) ? today : addDays(today, -1)
764  while (set.has(day)) {
765    current += 1
766    day = addDays(day, -1)
767  }
768  return { longest, longestEnd, current }
769}
770
771/** Le mois en cours : dépensé, projection linéaire à la fin du mois, et le mois d'avant. */
772export function monthPlan(rows: readonly UsageRow[], today: string, metric: Metric) {
773  const month = monthOf(today)
774  const prev = monthOf(addDays(`${month}-01`, -1))
775  const day = Number(today.slice(8, 10))
776  const total = daysInMonth(month)
777  let spent = 0
778  let lastMonth = 0
779  let lastMonthToDate = 0
780  for (const r of rows) {
781    const v = metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead
782    const m = monthOf(r.date)
783    if (m === month && r.date <= today) spent += v
784    else if (m === prev) {
785      lastMonth += v
786      if (Number(r.date.slice(8, 10)) <= day) lastMonthToDate += v
787    }
788  }
789  return { month, prev, day, total, spent, projected: day > 0 ? (spent / day) * total : spent, lastMonth, lastMonthToDate }
790}
791
792/** Le total de la mesure par jour de la semaine, lundi d'abord. */
793export function weekdaysOf(rows: readonly UsageRow[], metric: Metric): number[] {
794  const out = [0, 0, 0, 0, 0, 0, 0]
795  for (const r of rows) out[weekdayOf(r.date)]! += metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead
796  return out
797}
798
799// Ordres de grandeur en tokens (estimations : ~1,4 token par mot français).
800const BOOKS = [
801  { title: 'Le Petit Prince', tokens: 25_000 },
802  { title: "Harry Potter à l'école des sorciers", tokens: 110_000 },
803  { title: 'Les Misérables', tokens: 750_000 },
804  { title: 'À la recherche du temps perdu', tokens: 1_800_000 },
805] as const
806
807/** Une comparaison parlante pour les tokens de sortie : le plus gros livre écrit au moins trois fois. */
808export function funFact(output: number): string | null {
809  if (output < 1000) return null
810  const book = [...BOOKS].reverse().find((b) => output / b.tokens >= 3) ?? BOOKS[0]
811  const ratio = output / book.tokens
812  const times = ratio >= 10 ? fmtInt(ratio) : dec(ratio, 1)
813  return `Claude t'a écrit ~${times}${NB}× ${book.title} (${fmtTokens(output)} tokens de sortie).`
814}
815
816// ─── Journée ────────────────────────────────────────────────────────────────
817
818/** Une discussion d'un jour : la session principale et ses sous-agents de workflow, réunis. */
819export type DayDiscussion = { id: string; machine: string; project: string; first: number; last: number; cost: number; tokens: number; models: string[]; agents: number }
820
821/**
822 * Les discussions actives un jour donné : commencées ou terminées ce jour-là (ccusage ne donne
823 * que la première et la dernière activité). Une discussion compte avec tous ses sous-agents de
824 * workflow dès que l'un d'eux est actif ce jour-là ; son coût est son total, jours précédents
825 * compris. Les plus chères d'abord.
826 */
827export function discussionsOfDay(sessions: readonly SessionRow[], day: string, tzOffset: number): DayDiscussion[] {
828  const isOn = (s: SessionRow) => dayOf(s.first, tzOffset) === day || dayOf(s.last, tzOffset) === day
829  const ids = new Set(sessions.filter(isOn).map((s) => s.parent ?? s.id))
830  const out = new Map<string, DayDiscussion>()
831  for (const s of sessions) {
832    const id = s.parent ?? s.id
833    if (!ids.has(id)) continue
834    const d = out.get(id) ?? { id, machine: s.machine, project: s.project, first: s.first, last: s.last, cost: 0, tokens: 0, models: [], agents: 0 }
835    if (!s.parent) {
836      d.machine = s.machine
837      d.project = s.project
838    } else d.agents += 1
839    d.first = Math.min(d.first, s.first)
840    d.last = Math.max(d.last, s.last)
841    d.cost += s.cost
842    d.tokens += s.tokens
843    d.models = unique([...d.models, ...s.models])
844    out.set(id, d)
845  }
846  return [...out.values()].sort((a, b) => b.cost - a.cost || b.last - a.last)
847}
848
849/**
850 * Un jour dans les 30 derniers jours, lui compris : la veille, la moyenne par jour actif avant
851 * lui dans la fenêtre, et son rang parmi les jours actifs de la fenêtre (null s'il n'a rien consommé).
852 */
853export function compareDay(rows: readonly UsageRow[], day: string, metric: Metric): { value: number; previous: number; average: number | null; rank: number | null; activeDays: number } {
854  const from = addDays(day, -29)
855  const byDate = new Map<string, number>()
856  for (const r of rows) {
857    if (r.date < from || r.date > day) continue
858    byDate.set(r.date, (byDate.get(r.date) ?? 0) + (metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead))
859  }
860  const value = byDate.get(day) ?? 0
861  const before = [...byDate.entries()].filter(([d, v]) => d < day && v > 0).map(([, v]) => v)
862  return {
863    value,
864    previous: byDate.get(addDays(day, -1)) ?? 0,
865    average: before.length ? before.reduce((n, v) => n + v, 0) / before.length : null,
866    rank: value > 0 ? 1 + before.filter((v) => v > value).length : null,
867    activeDays: before.length + (value > 0 ? 1 : 0),
868  }
869}
870
871/**
872 * Le jour montré par l'onglet Journée : le jour choisi, ramené entre le premier jour connu et
873 * celui du relevé (rien n'existe après lui), ou celui du relevé. Avec les jours actifs voisins
874 * (`dates`, sous les filtres), null quand il n'y en a pas.
875 */
876export function dayChoice(chosen: string | null, latest: string, first: string | null, dates: readonly string[]): { day: string; first: string; latest: string; prevActive: string | null; nextActive: string | null } {
877  const start = first !== null && first < latest ? first : latest
878  const day = chosen === null || chosen > latest ? latest : chosen < start ? start : chosen
879  let prevActive: string | null = null
880  let nextActive: string | null = null
881  for (const d of dates) {
882    if (d < day && d >= start && (prevActive === null || d > prevActive)) prevActive = d
883    if (d > day && d <= latest && (nextActive === null || d < nextActive)) nextActive = d
884  }
885  return { day, first: start, latest, prevActive, nextActive }
886}
887
888/** Les blocs de 5 h commencés ou terminés ce jour-là. */
889export const blocksOfDay = (blocks: readonly BlockRow[], day: string, tzOffset: number): BlockRow[] =>
890  blocks.filter((b) => dayOf(b.start, tzOffset) === day || dayOf(b.lastAt, tzOffset) === day)
891
892/** Le rythme d'un bloc : celui de ccusage pour le bloc en cours, sinon sa dépense par heure active (comme les seuils) ; null sous 6 min. */
893export function blockRate(b: BlockRow): number | null {
894  if (b.costPerHour !== null) return b.costPerHour
895  const hours = (b.lastAt - b.start) / 3_600_000
896  return hours > 0.1 ? b.cost / hours : null
897}
898
899// ─── Rythme horaire ─────────────────────────────────────────────────────────
900
901/**
902 * Les seuils du rythme horaire, en $/h : tirés des 45 blocs de 5 h du 9 septembre au 7 octobre
903 * 2026 (dépense du bloc par heure active) : 1er quartile 6,9, médiane 13,5, 3e quartile 21,7,
904 * 9e décile 31,9, maximum 71.
905 */
906export const RATE_LEVELS = [7, 14, 22, 32] as const
907
908export type Heat = { level: 0 | 1 | 2 | 3 | 4; label: string; color: string }
909
910const HEATS: readonly Heat[] = [
911  { level: 0, label: 'calme', color: C.teal },
912  { level: 1, label: 'posé', color: C.amber },
913  { level: 2, label: 'soutenu', color: C.tangerine },
914  { level: 3, label: 'intense', color: C.coral },
915  { level: 4, label: 'en feu', color: C.red },
916]
917
918/** Le niveau d'un rythme horaire : calme, posé, soutenu, intense, en feu. */
919export const heatOf = (perHour: number): Heat => HEATS[RATE_LEVELS.filter((t) => perHour >= t).length]!
920
921/** La légende des niveaux : « calme < 7 · posé 7–14 · … · en feu ≥ 32 $/h ». */
922export const heatLegend = (): string =>
923  HEATS.map((h, i) => (i === 0 ? `${h.label} <${NB}${RATE_LEVELS[0]}` : i === HEATS.length - 1 ? `${h.label} ≥${NB}${RATE_LEVELS[i - 1]}` : `${h.label} ${RATE_LEVELS[i - 1]}–${RATE_LEVELS[i]}`)).join(' · ') + `${NB}$/h`
924
925// ─── Discussion ouverte ─────────────────────────────────────────────────────
926
927/** `ccusage claude session --id <id> --json` : `{ sessionId, totalCost, totalTokens, entries }`. */
928export function discussionOf(json: Json): { cost: number; tokens: number; entries: DiscussionEntry[] } {
929  const entries: DiscussionEntry[] = []
930  for (const e of Array.isArray(json?.entries) ? json.entries : []) {
931    const at = Date.parse(str(e?.timestamp))
932    const model = str(e?.model)
933    if (!Number.isFinite(at) || !model || model === '<synthetic>') continue
934    entries.push([at, model, num(e.inputTokens), num(e.outputTokens), num(e.cacheCreationTokens), num(e.cacheReadTokens)])
935  }
936  entries.sort((a, b) => a[0] - b[0])
937  return { cost: num(json?.totalCost), tokens: num(json?.totalTokens), entries }
938}
939
940/** Les poids relatifs du barème d'Anthropic, l'entrée valant 1 : cache écrit 1,25, cache lu 0,1, sortie 5. */
941const units = (input: number, output: number, cacheWrite: number, cacheRead: number): number => input + 1.25 * cacheWrite + 0.1 * cacheRead + 5 * output
942
943/** Le prix d'une unité pondérée, par modèle, d'après le coût et les tokens de tout le relevé. */
944export function unitPricesOf(rows: readonly UsageRow[]): Record<string, number> {
945  const acc = new Map<string, { cost: number; units: number }>()
946  for (const r of rows) {
947    const x = acc.get(r.model) ?? { cost: 0, units: 0 }
948    x.cost += r.cost
949    x.units += units(r.input, r.output, r.cacheWrite, r.cacheRead)
950    acc.set(r.model, x)
951  }
952  const out: Record<string, number> = {}
953  for (const [model, x] of acc) if (x.cost > 0 && x.units > 0) out[model] = x.cost / x.units
954  return out
955}
956
957/**
958 * Le coût estimé de chaque appel : ccusage ne donne que le total de la discussion. Chaque appel
959 * reçoit ses unités pondérées (barème relatif) au prix unitaire de son modèle, puis le tout est
960 * ramené au total exact de ccusage.
961 */
962export function estimateCosts(entries: readonly DiscussionEntry[], total: number, prices: Record<string, number>): number[] {
963  const known = Object.values(prices)
964  const fallback = known.length ? known.reduce((n, v) => n + v, 0) / known.length : 1
965  const raw = entries.map(([, model, i, o, cw, cr]) => units(i, o, cw, cr) * (prices[model] ?? fallback))
966  const all = raw.reduce((n, v) => n + v, 0)
967  return all > 0 ? raw.map((v) => (v / all) * total) : raw.map(() => 0)
968}
969
970export const entryTokens = (e: DiscussionEntry): number => e[2] + e[3] + e[4] + e[5]
971
972/** La durée d'un seau de la frise : la plus courte qui tient en 60 seaux au plus. */
973export function bucketMinutes(from: number, to: number, slots = 60): number {
974  const span = Math.max(1, (to - from) / 60_000)
975  return [5, 15, 30, 60, 180, 360, 720, 1440, 2880].find((m) => span / m <= slots) ?? 10_080
976}
977
978/** Le texte de la ligne d'état quand le bandeau est masqué. */
979export function statusText(o: { perHour: number | null; discussion: number | null; today: number }): string {
980  const parts: string[] = []
981  if (o.perHour !== null) parts.push(`${o.perHour >= RATE_LEVELS[3] ? '🔥' : '◆'} ${fmtCostShort(o.perHour)}/h`)
982  if (o.discussion !== null) parts.push(`discussion ${fmtCost(o.discussion)}`)
983  parts.push(`${fmtCost(o.today)} aujourd'hui`)
984  return parts.join(' · ').replace(new RegExp(NB, 'g'), ' ')
985}
986
hooks/view.tsx 2074 lines
1// Dessin du panneau de statistiques (/ccstats) et du bandeau au-dessus du prompt. Un seul
2// arbre pour toutes les surfaces : le desktop, VS Code et le mobile reçoivent des SVG, le
3// terminal des grilles de cases (`Raster`) pour le calendrier et les colonnes, des glyphes et
4// des barres en blocs pour le reste (et pour tout, si sa table n'a pas `Raster`). Aucun appel
5// au moteur ici : l'état arrive dans `View`, les actions dans `Actions` (des fermetures préparées par register.tsx).
6//
7// Pièges du desktop (relevés par git-board) : les espaces ordinaires y sont fondus et `gap`
8// ignoré ; les écarts dans un texte sont donc insécables, ceux entre éléments des marges.
9// Aucune prop n'est passée à `undefined` : les arbres restent valides partout.
10
11import type { Discussion, Expanded, Grain, Machine, Meta, Metric, Notice, Period, SessionRow, SessionSort, Snapshot, Tab, Ui, UsageRow } from '../types'
12import {
13  BRAND,
14  C,
15  DAY_LETTERS,
16  DRAFTS,
17  MONEY,
18  NB,
19  RATE_LEVELS,
20  SEP,
21  WEEKDAY_LONG,
22  activeDates,
23  blockRate,
24  blocksOfDay,
25  bucketMinutes,
26  compareDay,
27  dayChoice,
28  discussionsOfDay,
29  entryTokens,
30  estimateCosts,
31  heatLegend,
32  heatOf as rateHeat,
33  unitPricesOf,
34  addDays,
35  ago,
36  cacheRatio,
37  dayOf,
38  daysBetween,
39  filterOf,
40  firstDate,
41  fitGrain,
42  fmtCost,
43  fmtCostShort,
44  fmtDate,
45  fmtDay,
46  fmtDelta,
47  fmtDuration,
48  fmtInt,
49  fmtMetric,
50  fmtMonth,
51  fmtMonthShort,
52  fmtPct,
53  fmtTokens,
54  funFact,
55  groupOf,
56  modelColors,
57  modelName,
58  modelsOf,
59  monthOf,
60  monthPlan,
61  monthShort,
62  plural,
63  previousOf,
64  projectLabels,
65  projectsOf,
66  selectRows,
67  selectSessions,
68  seriesOf,
69  streaksOf,
70  sum,
71  unique,
72  valueOf,
73  weekOf,
74  weekdaysOf,
75  zero,
76  type Filter,
77  type Totals,
78} from './model.ts'
79import { DEFAULT, columnsCells, heatCells, heatLegendCells, pack, type RasterCells } from './raster.ts'
80import { barSvg, blockSvg, columnsSvg, donutSvg, flameSvg, heatGaugeSvg, heatmapSvg, heroSvg, ringSvg, sparkSvg, spinnerSvg, stackSvg, type BarPart, type HeatCell } from './svg.ts'
81
82/** Largeur estimée d'une cellule sur les surfaces distantes ; un SVG trop large est réduit à sa place. */
83const PX_PER_CELL = 7.6
84/** Plafonds des listes avant « ▸ tout voir (N) ». */
85const MODELS_CAP = 6
86const PROJECTS_CAP = 8
87const SESSIONS_CAP = 10
88const SESSIONS_MAX = 40
89const DAYS_CAP = 5
90const DAYS_MAX = 15
91
92export type Kit = {
93  Box: any
94  Text: any
95  Button: any
96  Svg: any
97  /**
98   * La grille de cases du terminal, ou null : les graphiques passent alors aux glyphes. Jamais
99   * donnée à une surface qui dessine les SVG : la table complétée de `$.ui.resolve` y porte un
100   * `Raster` (fragment vide, constaté dans le kit de test), et le SVG garde la priorité.
101   */
102  Raster: any
103  /** La surface dessine les SVG (desktop, VS Code, mobile). */
104  isRich: boolean
105  /** Colonnes du corps. */
106  width: number
107}
108
109/**
110 * Ce que la surface dessine vraiment. La table de `$.ui.resolve(e)` est complétée : un
111 * élément absent de la surface y figure quand même et dessine un fragment vide (constaté par
112 * git-board : `Svg` sur le terminal). register.tsx lit donc la capacité dans le contrat
113 * `Elements` du moteur et la passe ici ; la vue ne connaît pas le nom de la surface.
114 */
115export type Caps = { hasSvg: boolean }
116
117/** Une case vide, pour essayer le constructeur `Raster` de la table. */
118const PROBE = pack([[[0x20, DEFAULT, DEFAULT]]])
119
120/**
121 * La table a vraiment `Raster` : le constructeur existe et rend un élément `Raster`. Un moteur
122 * qui ne le connaît pas n'en a pas ; un `Raster` retenu par un hook `ui.resolve` est complété
123 * par un fragment vide (constaté dans le kit de test), qui ne dessinerait rien.
124 */
125const drawsRaster = (ctor: unknown): boolean => {
126  if (typeof ctor !== 'function') return false
127  try {
128    return (ctor as (props: unknown) => { type?: unknown } | null)({ key: 'probe', columns: 1, rows: 1, cells: PROBE.cells })?.type === 'Raster'
129  } catch {
130    return false
131  }
132}
133
134export const kitOf = (table: Record<string, unknown>, width: number, caps: Caps): Kit => ({
135  Box: table.Box,
136  Text: table.Text,
137  Button: table.Button,
138  Svg: caps.hasSvg ? (table.Svg ?? null) : null,
139  Raster: !caps.hasSvg && drawsRaster(table.Raster) ? table.Raster : null,
140  isRich: caps.hasSvg && typeof table.Svg === 'function',
141  width: Math.max(24, Math.floor(width)),
142})
143
144export type View = {
145  snapshot: Snapshot | null
146  ui: Ui
147  meta: Meta
148  notice: Notice
149  isBandHidden: boolean
150  /** Clé ccusage du dossier de la session. */
151  here: string | null
152  discussion: Discussion
153  now: number
154}
155
156export type Actions = {
157  setUi: (patch: Partial<Ui>) => unknown
158  toggleExpanded: (list: keyof Expanded) => unknown
159  /** ↻ : relance ccusage, relit le cache de /stats et détaille la discussion. */
160  refresh: () => unknown
161  /** ↻ de l'onglet Discussion : ccusage sur cette seule discussion. */
162  refreshDiscussion: () => unknown
163  dismissNotice: () => unknown
164  hideBand: () => unknown
165  openPane: () => unknown
166}
167
168// ─── Petits outils ────────────────────────────────────────────────────────
169
170const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
171const spinFrame = (now: number): string => SPIN[Math.floor(now / 1000) % SPIN.length] ?? '⠋'
172const px = (cells: number): number => Math.max(160, Math.floor(cells * PX_PER_CELL))
173const EIGHTHS = '▁▂▃▄▅▆▇█'
174
175type Style = { color?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; wrap?: string }
176
177const propsOf = (style: Style): Record<string, unknown> => {
178  const props: Record<string, unknown> = {}
179  if (style.color) props.color = style.color
180  if (style.bg) props.backgroundColor = style.bg
181  if (style.bold) props.bold = true
182  if (style.dim) props.dimColor = true
183  if (style.italic) props.italic = true
184  if (style.wrap) props.wrap = style.wrap
185  return props
186}
187
188const isShown = (item: unknown): boolean => item !== null && item !== undefined && item !== false && item !== ''
189
190/** Un Text sans prop indéfinie. */
191const T = (k: Kit, text: string, style: Style = {}): unknown => h(k.Text, propsOf(style), text)
192
193/** Un Text fait de morceaux (Text imbriqués ou chaînes), sans prop indéfinie. */
194const TT = (k: Kit, parts: unknown[], style: Style = {}): unknown => h(k.Text, propsOf(style), ...parts.filter(isShown))
195
196/** Pastille pleine : texte foncé sur la couleur. */
197const chip = (k: Kit, text: string, color: string, style: Style = {}): unknown => T(k, `${NB}${text}${NB}`, { ...style, bg: color, color: C.ink, bold: true })
198
199/** Une rangée d'éléments séparés par une marge (le desktop ignore `gap`). */
200const line = (k: Kit, items: unknown[], wrap = false): unknown => {
201  const { Box } = k
202  const list = items.filter(isShown)
203  return (
204    <Box flexDirection="row" alignItems="center" flexWrap={wrap ? 'wrap' : 'nowrap'}>
205      {list.map((item, i) => (i < list.length - 1 ? <Box marginRight={1}>{item}</Box> : item))}
206    </Box>
207  )
208}
209
210const svgOf = (k: Kit, drawn: { source: string; width: number; height: number }, alt: string, isSized = true): unknown => {
211  const props: Record<string, unknown> = { source: drawn.source, alt }
212  if (isSized) {
213    props.width = drawn.width
214    props.height = drawn.height
215  }
216  // Jamais `isInteractive` : ce mode dessine dans un cadre à fond blanc, rechargé à chaque redessin.
217  return h(k.Svg, props)
218}
219
220/** Barre en blocs du terminal. */
221const blocks = (k: Kit, parts: readonly BarPart[], total: number, cells: number): unknown => {
222  const { Box } = k
223  const width = Math.max(4, cells)
224  const all = Math.max(1e-9, total, parts.reduce((n, p) => n + p.value, 0))
225  let used = 0
226  const segs: unknown[] = []
227  parts.forEach((part) => {
228    if (part.value <= 0) return
229    const n = Math.max(1, Math.round((part.value / all) * width))
230    const take = Math.min(n, width - used)
231    if (take <= 0) return
232    used += take
233    segs.push(T(k, '█'.repeat(take), { color: part.color }))
234  })
235  if (used < width) segs.push(T(k, '░'.repeat(width - used), { color: C.gray, dim: true }))
236  return <Box flexDirection="row">{segs}</Box>
237}
238
239/** Une jauge horizontale : SVG arrondi, ou blocs sur le terminal. */
240const meter = (k: Kit, parts: readonly BarPart[], total: number, cells: number, alt: string): unknown =>
241  k.isRich ? svgOf(k, barSvg(parts, total, px(cells), 7), alt) : blocks(k, parts, total, cells)
242
243/** Indicateur de travail : arc qui tourne (SVG) ou spinner braille (terminal). */
244const spinner = (k: Kit, color: string, now: number, alt: string): unknown => (k.isRich ? svgOf(k, spinnerSvg(color, 12), alt) : T(k, spinFrame(now), { color }))
245
246/** Bascule « ▸ tout voir (N) » / « ▾ réduire » d'une liste plafonnée. */
247const showAllToggle = (k: Kit, key: string, isOpen: boolean, total: number, onPress: () => unknown, more = `tout${NB}voir${NB}(${total})`): unknown => {
248  const { Box, Button } = k
249  return (
250    <Box marginTop={1}>
251      <Button key={key} plain={true} dimColor={true} label={isOpen ? `▾${NB}réduire` : `▸${NB}${more}`} onPress={onPress} />
252    </Box>
253  )
254}
255
256/** Un choix d'une rangée de bascules : le choix courant marqué « ▸ », les autres atténués. */
257const toggle = (k: Kit, key: string, label: string, isOn: boolean, onPress: () => unknown): unknown =>
258  h(k.Button, { key, plain: true, dimColor: !isOn, label: isOn ? `▸${NB}${label}` : label, onPress })
259
260/** Une valeur seule par jour, échantillonnée sur `cells` colonnes au plus (sommes par paquet). */
261const resample = (values: readonly number[], cells: number): number[] => {
262  if (values.length <= cells) return [...values]
263  const size = Math.ceil(values.length / cells)
264  const out: number[] = []
265  for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size).reduce((n, v) => n + v, 0))
266  return out
267}
268
269/** Une courbe cumulée, échantillonnée sur `cells` points au plus : la dernière valeur de chaque paquet. */
270const sampleLast = (values: readonly number[], cells: number): number[] => {
271  if (values.length <= cells) return [...values]
272  const size = Math.ceil(values.length / cells)
273  const out: number[] = []
274  for (let i = size - 1; i < values.length + size - 1; i += size) out.push(values[Math.min(i, values.length - 1)]!)
275  return out
276}
277
278/** Courbe d'une ligne en huitièmes de bloc (terminal). */
279const sparkText = (k: Kit, values: readonly number[], cells: number, color: string): unknown => {
280  const vals = resample(values, Math.max(4, cells))
281  const max = Math.max(...vals, 1e-9)
282  const text = vals.map((v) => (v <= 0 ? '▁' : EIGHTHS[Math.min(7, Math.max(0, Math.ceil((v / max) * 8) - 1))] ?? '▁')).join('')
283  return T(k, text, { color })
284}
285
286/**
287 * Colonnes sur plusieurs rangées en huitièmes de bloc (terminal) ; `cell` caractères par
288 * colonne. Avec `stacks` (les parts de chaque colonne, du bas vers le haut), chaque case prend
289 * la couleur de la part qu'elle couvre : des barres empilées par modèle.
290 */
291const columnsText = (k: Kit, values: readonly number[], height: number, color: string, cell = 1, highlight = -1, stacks: readonly (readonly BarPart[])[] | null = null): unknown => {
292  const { Box } = k
293  const max = Math.max(...values, 1e-9)
294  const tall = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * height * 8)) : 0))
295  const colorAt = (i: number, at8: number): string => {
296    const parts = stacks?.[i]
297    const all = values[i] ?? 0
298    if (!parts || all <= 0) return i === highlight ? C.coral : color
299    let top = 0
300    for (const p of parts) {
301      top += (Math.max(0, p.value) / all) * (tall[i] ?? 0)
302      if (at8 < top) return p.color
303    }
304    return parts[parts.length - 1]?.color ?? color
305  }
306  const rows: unknown[] = []
307  for (let r = height - 1; r >= 0; r--) {
308    const runs: { text: string; color: string }[] = []
309    tall.forEach((h8, i) => {
310      const fill = Math.max(0, Math.min(8, h8 - r * 8))
311      const glyph = fill === 0 ? NB : (EIGHTHS[fill - 1] ?? '█')
312      const text = cell > 1 ? glyph.repeat(cell - 1) + NB : glyph
313      const c = colorAt(i, r * 8 + fill / 2)
314      const last = runs[runs.length - 1]
315      if (last && last.color === c) last.text += text
316      else runs.push({ text, color: c })
317    })
318    rows.push(<Box key={`r${r}`}>{TT(k, runs.map((x) => T(k, x.text, { color: x.color })))}</Box>)
319  }
320  return <Box flexDirection="column">{rows}</Box>
321}
322
323/** Une grille de cases (`Raster`) ; `key` unique parmi celles d'un même arbre. */
324const raster = (k: Kit, key: string, drawn: RasterCells): unknown => h(k.Raster, { key, columns: drawn.columns, rows: drawn.rows, cells: drawn.cells })
325
326/** Colonnes du terminal : une grille de cases quand la table a `Raster`, sinon des glyphes. */
327const columnsChart = (k: Kit, key: string, values: readonly number[], height: number, color: string, cell = 1, highlight = -1, stacks: readonly (readonly BarPart[])[] | null = null): unknown =>
328  k.Raster && values.length > 0 && values.length * cell <= 512 ? raster(k, key, columnsCells(values, height, color, cell, highlight, stacks)) : columnsText(k, values, height, color, cell, highlight, stacks)
329
330const TONE = { ok: C.green, error: C.red, info: C.tangerine } as const
331const toneOf = (metric: Metric): string => (metric === 'cost' ? MONEY : BRAND)
332const hhmm = (ms: number, tz: number): string => new Date(ms - tz * 60_000).toISOString().slice(11, 16)
333const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
334
335// ─── Contexte d'un dessin ─────────────────────────────────────────────────
336
337type Ctx = {
338  s: Snapshot
339  ui: Ui
340  now: number
341  today: string
342  f: Filter
343  rows: UsageRow[]
344  total: Totals
345  /** Premier jour montré : début de la période, ou premier jour connu pour « Tout ». */
346  from: string
347  metric: Metric
348  colors: Record<string, string>
349  names: Map<string, string>
350  sessions: SessionRow[]
351  /** Le projet de la session, tel qu'affiché (les brouillons réunis). */
352  here: string | null
353  /** Colonnes dans une carte bordée. */
354  inner: number
355}
356
357function contextOf(k: Kit, view: View): Ctx | null {
358  const s = view.snapshot
359  if (!s) return null
360  const today = dayOf(view.now, s.tzOffset)
361  const machine = view.ui.machine === 'all' || s.machines.includes(view.ui.machine) ? view.ui.machine : 'all'
362  const f = filterOf({ ...view.ui, machine }, today)
363  const rows = selectRows(s.rows, f, s.projects)
364  const homeKey = Object.entries(s.projects).find(([, i]) => i.name === '~')?.[0] ?? null
365  const keys = unique([...s.rows.map((r) => groupOf(r.project, s.projects)), ...s.sessions.map((x) => groupOf(x.project, s.projects))])
366  return {
367    s,
368    ui: view.ui,
369    now: view.now,
370    today,
371    f,
372    rows,
373    total: sum(rows),
374    from: f.from ?? firstDate(rows) ?? today,
375    metric: view.ui.metric,
376    // Couleurs sur tout le relevé, pas sur la vue filtrée : un modèle garde la sienne d'un filtre à l'autre.
377    colors: modelColors(modelsOf(s.rows, 'tokens').map((m) => m.model)),
378    names: projectLabels(keys, s.projects, homeKey),
379    sessions: selectSessions(s.sessions, f, s.projects, s.tzOffset),
380    here: view.here ? groupOf(view.here, s.projects) : null,
381    inner: Math.max(20, k.width - 6),
382  }
383}
384
385const nameOf = (ctx: Ctx, key: string): string => ctx.names.get(key) ?? key
386
387/** Une carte bordée : un titre de couleur, des réglages à droite (qui passent dessous si la place manque). */
388const card = (k: Kit, key: string, title: string, color: string, right: unknown, body: unknown[]): unknown => {
389  const { Box } = k
390  return (
391    <Box key={key} flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
392      <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
393        <Box flexShrink={1} marginRight={1}>
394          {T(k, title, { color, bold: true, wrap: 'truncate-end' })}
395        </Box>
396        {isShown(right) && <Box flexShrink={0}>{right}</Box>}
397      </Box>
398      {body.filter(isShown)}
399    </Box>
400  )
401}
402
403const say = (k: Kit, text: string, style: Style = { dim: true }): unknown => {
404  const { Box } = k
405  return <Box marginTop={1}>{T(k, text, { wrap: 'wrap', ...style })}</Box>
406}
407
408const metricToggle = (k: Kit, ctx: Ctx, act: Actions): unknown =>
409  line(k, [
410    toggle(k, 'metric-cost', `$${NB}coût`, ctx.metric === 'cost', () => act.setUi({ metric: 'cost' })),
411    toggle(k, 'metric-tokens', `#${NB}tokens`, ctx.metric === 'tokens', () => act.setUi({ metric: 'tokens' })),
412  ])
413
414/** Des pastilles de couleur, une par modèle. */
415const modelDots = (k: Kit, ctx: Ctx, models: readonly string[]): unknown => TT(k, models.map((m) => T(k, '●', { color: ctx.colors[m] ?? C.gray })))
416
417// ─── Panneau ──────────────────────────────────────────────────────────────
418
419const TABS: [Tab, string][] = [
420  ['discussion', 'Discussion'],
421  ['day', 'Journée'],
422  ['overview', 'Aperçu'],
423  ['trend', 'Évolution'],
424  ['models', 'Modèles'],
425  ['projects', 'Projets'],
426  ['sessions', 'Sessions'],
427  ['rhythm', 'Rythme'],
428]
429
430export const drawPane = (k: Kit, view: View, act: Actions): unknown => {
431  const { Box } = k
432  const ctx = contextOf(k, view)
433  if (!ctx) {
434    return (
435      <Box flexDirection="column" paddingX={1} paddingBottom={1}>
436        {welcome(k, view, act)}
437        {activity(k, view, act)}
438      </Box>
439    )
440  }
441  const tab = view.ui.tab
442  const body =
443    tab === 'discussion'
444      ? discussionTab(k, view, ctx, act)
445      : tab === 'day'
446      ? dayTab(k, view, ctx, act)
447      : ctx.rows.length === 0
448      ? emptyCard(k, act)
449      : tab === 'trend'
450        ? trendTab(k, ctx, act)
451        : tab === 'models'
452          ? modelsTab(k, ctx, act)
453          : tab === 'projects'
454            ? projectsTab(k, ctx, act)
455            : tab === 'sessions'
456              ? sessionsTab(k, ctx, act)
457              : tab === 'rhythm'
458                ? rhythmTab(k, ctx, act)
459                : overviewTab(k, ctx, act)
460  return (
461    <Box flexDirection="column" paddingX={1} paddingBottom={1}>
462      {header(k, view, ctx, act)}
463      {activity(k, view, act)}
464      {tabsRow(k, view, act)}
465      {body}
466    </Box>
467  )
468}
469
470/** Sans relevé : une carte d'accueil, jamais un panneau vide. */
471const welcome = (k: Kit, view: View, act: Actions): unknown => {
472  const { Box, Button } = k
473  const m = view.meta
474  return (
475    <Box key="welcome" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={2} paddingY={1}>
476      {T(k, `◆${NB}Stats${NB}Claude${NB}Code`, { color: BRAND, bold: true })}
477      {T(
478        k,
479        m.loading
480          ? 'Premier relevé en cours : ccusage lit les journaux de Claude Code, puis le mod publie ceux de ce poste et ajoute ceux des autres postes.'
481          : 'Aucun relevé pour l’instant. Le relevé lance « npx ccusage@latest » (coût, tokens, sessions, blocs de 5 h), lit le cache de /stats et ajoute les usages des autres postes.',
482        { dim: true, wrap: 'wrap' },
483      )}
484      {m.error && <Box marginTop={1}>{T(k, m.error, { color: C.red, wrap: 'wrap' })}</Box>}
485      {!m.loading && (
486        <Box marginTop={1}>
487          <Button key="refresh" variant="primary" label={`▶${NB}Lancer${NB}le${NB}relevé`} onPress={() => act.refresh()} />
488        </Box>
489      )}
490    </Box>
491  )
492}
493
494const emptyCard = (k: Kit, act: Actions): unknown => {
495  const { Box, Button } = k
496  return (
497    <Box key="empty" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
498      {T(k, 'Rien à montrer pour ces filtres.', { bold: true })}
499      {T(k, 'Aucune consommation sur cette période, ce poste, ce projet ou ce modèle.', { dim: true, wrap: 'wrap' })}
500      <Box marginTop={1}>
501        <Button key="reset-filters" label={`↺${NB}Tout${NB}afficher`} onPress={() => act.setUi({ period: 'all', machine: 'all', project: null, model: null })} />
502      </Box>
503    </Box>
504  )
505}
506
507const PERIODS: [Period, string][] = [
508  ['all', 'Tout'],
509  ['90', `90${NB}j`],
510  ['30', `30${NB}j`],
511  ['7', `7${NB}j`],
512]
513/** Les bascules de poste : tous, puis chaque poste du relevé (celui-ci d'abord) ; rien avec un seul poste. */
514const machineToggles = (s: Snapshot): [Machine, string][] =>
515  s.machines.length > 1 ? [['all', `Tous${NB}les${NB}postes`], ...s.machines.map((m): [Machine, string] => [m, m.replace(/ /g, NB)])] : []
516
517/** Les données que seul ce poste connaît (cache de /stats, blocs de 5 h) valent pour la vue. */
518const isLocalView = (ctx: Ctx): boolean => ctx.f.machine === 'all' || ctx.f.machine === ctx.s.machine
519
520/** Le poste choisi n'a publié que des totaux par jour (l'ancien serveur) : pas de sessions. */
521const lacksSessions = (ctx: Ctx): boolean => ctx.f.machine !== 'all' && ctx.s.sessionless.includes(ctx.f.machine)
522
523/** Carte d'en-tête : titre, fraîcheur et ↻, période, machine, filtres, couverture, avertissements. */
524const header = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
525  const { Box, Button } = k
526  const s = ctx.s
527  const m = view.meta
528  const ui = ctx.ui
529  const status = m.loading ? T(k, `relevé${NB}en${NB}cours…`, { color: C.tangerine }) : T(k, `relevé${NB}${ago(s.fetchedAt, view.now)}`, { dim: true })
530  const days = daysBetween(ctx.from, ctx.today) + 1
531  const coverage = [
532    `${fmtDate(ctx.from, ctx.from.slice(0, 4) !== ctx.today.slice(0, 4))}${NB}→${NB}${fmtDate(ctx.today)}`,
533    plural(days, 'jour'),
534    s.version ? `ccusage${NB}${s.version}` : null,
535    s.isOffline ? `prix${NB}hors${NB}ligne` : null,
536  ]
537    .filter(Boolean)
538    .join(SEP)
539  const isHereOn = ctx.here !== null && ui.project === ctx.here
540  const hasHere = ctx.here !== null && s.rows.some((r) => groupOf(r.project, s.projects) === ctx.here)
541  const machines = machineToggles(s)
542  const filters = [
543    ui.project && !isHereOn && <Button key="clear-project" plain={true} label={`⌂${NB}${nameOf(ctx, ui.project)}${NB}✕`} onPress={() => act.setUi({ project: null })} />,
544    ui.model && <Button key="clear-model" plain={true} label={`◆${NB}${modelName(ui.model)}${NB}✕`} onPress={() => act.setUi({ model: null })} />,
545  ].filter(isShown)
546  const unpriced = s.unpriced.map(modelName)
547  return (
548    <Box key="head" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
549      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
550        <Box flexShrink={1}>{chip(k, `◆${NB}Stats${NB}Claude${NB}Code`, BRAND, { wrap: 'truncate-end' })}</Box>
551        <Box flexDirection="row" flexShrink={0} marginLeft={1} alignItems="center">
552          <Box marginRight={1}>{status}</Box>
553          <Button key="refresh" plain={true} label={`↻${NB}Actualiser`} onPress={() => act.refresh()} />
554        </Box>
555      </Box>
556      <Box marginTop={1}>
557        {line(
558          k,
559          [
560            ...PERIODS.map(([p, label]) => toggle(k, `period-${p}`, label, ui.period === p, () => act.setUi({ period: p }))),
561            machines.length > 0 && T(k, '│', { dim: true }),
562            ...machines.map(([x, label]) => toggle(k, `machine-${x}`, label, ctx.f.machine === x, () => act.setUi({ machine: x }))),
563            hasHere && T(k, '│', { dim: true }),
564            hasHere && toggle(k, 'here', `⌂${NB}Ce${NB}projet`, isHereOn, () => act.setUi({ project: isHereOn ? null : ctx.here })),
565          ],
566          true,
567        )}
568      </Box>
569      {filters.length > 0 && <Box marginTop={1}>{line(k, [T(k, 'Filtres', { dim: true }), ...filters], true)}</Box>}
570      <Box marginTop={1}>{T(k, coverage, { dim: true, wrap: 'truncate-end' })}</Box>
571      {unpriced.length > 0 && T(k, `⚠${NB}Sans prix connu chez ccusage, compté 0${NB}$ : ${unpriced.join(', ')}`, { color: C.amber, wrap: 'wrap' })}
572      {s.keptDays > 0 && T(k, `↺${NB}${plural(s.keptDays, 'jour gardé', 'jours gardés')} par le mod : Claude Code a effacé leurs journaux.`, { dim: true, wrap: 'wrap' })}
573      {s.warnings.map((w) => T(k, `⚠${NB}${w}`, { color: C.amber, wrap: 'wrap' }))}
574    </Box>
575  )
576}
577
578/** La zone d'activité, toujours juste sous l'en-tête : relevé en cours, dernier message. */
579const activity = (k: Kit, view: View, act: Actions): unknown => {
580  const { Box, Button } = k
581  const m = view.meta
582  const said = view.notice
583  const secs = m.startedAt ? Math.max(0, Math.round((view.now - m.startedAt) / 1000)) : 0
584  const rows = [
585    m.loading && line(k, [spinner(k, C.tangerine, view.now, 'relevé'), T(k, `${m.loading}${secs >= 2 ? ` (${secs}${NB}s)` : ''}`, { color: C.tangerine, wrap: 'truncate-end' })]),
586    said && (
587      <Box flexDirection="row" justifyContent="space-between">
588        <Box flexShrink={1}>{T(k, said.text, { color: TONE[said.tone], wrap: 'wrap' })}</Box>
589        {said.tone === 'error' && (
590          <Box flexShrink={0} marginLeft={1}>
591            <Button key="notice-dismiss" plain={true} dimColor={true} label="x" onPress={() => act.dismissNotice()} />
592          </Box>
593        )}
594      </Box>
595    ),
596  ].filter(isShown)
597  if (rows.length === 0) return null
598  return (
599    <Box key="activity" flexDirection="column" marginTop={1} paddingX={1}>
600      {rows}
601    </Box>
602  )
603}
604
605const tabsRow = (k: Kit, view: View, act: Actions): unknown => {
606  const { Box, Button } = k
607  return (
608    <Box key="tabs" flexDirection="row" flexWrap="wrap" marginTop={1}>
609      {TABS.map(([tab, label]) => (
610        <Box marginRight={2}>
611          <Button key={`tab-${tab}`} plain={true} dimColor={tab !== view.ui.tab} label={tab === view.ui.tab ? `▸${NB}${label}` : label} onPress={() => act.setUi({ tab })} />
612        </Box>
613      ))}
614    </Box>
615  )
616}
617
618// ─── Rythme horaire : le feu ──────────────────────────────────────────────
619
620/** Les couleurs des cinq niveaux (calme → en feu), dans l'ordre de `heatOf`. */
621const HEAT_COLORS = [C.teal, C.amber, C.tangerine, C.coral, C.red] as const
622
623/** Le feu d'un rythme horaire : une flamme SVG ; sur le terminal, quatre crans colorés et 🔥 au dernier niveau. */
624const flame = (k: Kit, perHour: number, height = 14): unknown => {
625  const heat = rateHeat(perHour)
626  if (k.isRich) return svgOf(k, flameSvg(heat.level, height), `Rythme ${fmtCostShort(perHour)}/h : ${heat.label}`)
627  const notches = [1, 2, 3, 4].map((i) => T(k, i <= heat.level ? '▰' : '▱', i <= heat.level ? { color: HEAT_COLORS[i] } : { color: C.gray, dim: true }))
628  return TT(k, [...notches, heat.level === 4 ? T(k, `${NB}🔥`) : ''])
629}
630
631/** « 48 $/h » à la couleur de son niveau, en gras dès « intense ». */
632const rateText = (k: Kit, perHour: number, withLabel = false): unknown => {
633  const heat = rateHeat(perHour)
634  return T(k, `${fmtCostShort(perHour)}/h${withLabel ? `${NB}·${NB}${heat.label}` : ''}`, { color: heat.color, bold: heat.level >= 3 })
635}
636
637/** La jauge du rythme : SVG en dégradé et seuils, ou cinq zones de blocs colorées sur le terminal. */
638const heatGauge = (k: Kit, perHour: number, cells: number): unknown => {
639  if (k.isRich) return svgOf(k, heatGaugeSvg({ perHour, thresholds: RATE_LEVELS, colors: HEAT_COLORS, width: px(cells) }), `Jauge du rythme : ${fmtCostShort(perHour)}/h, ${rateHeat(perHour).label}`)
640  const { Box } = k
641  const zone = Math.max(2, Math.floor(cells / 5))
642  const edges = [0, ...RATE_LEVELS, RATE_LEVELS[3] * 2]
643  const parts: unknown[] = []
644  for (let i = 0; i < 5; i++) {
645    const lo = edges[i]!
646    const hi = edges[i + 1]!
647    const fill = perHour >= hi ? zone : perHour <= lo ? 0 : Math.max(1, Math.round(((perHour - lo) / (hi - lo)) * zone))
648    if (fill > 0) parts.push(T(k, '█'.repeat(fill), { color: HEAT_COLORS[i] }))
649    if (zone - fill > 0) parts.push(T(k, '░'.repeat(zone - fill), { color: HEAT_COLORS[i], dim: true }))
650  }
651  const ticks = RATE_LEVELS.map((t) => String(t).padStart(zone, NB)).join('')
652  return (
653    <Box flexDirection="column">
654      <Box>{TT(k, parts)}</Box>
655      {T(k, ticks, { dim: true })}
656    </Box>
657  )
658}
659
660// ─── Discussion ouverte ───────────────────────────────────────────────────
661
662/**
663 * Les chiffres de la discussion ouverte, une source chacun : le coût du moteur (comme /cost) ;
664 * les tokens de ccusage (détail au dernier ↻, sinon la session du relevé), plus les tours finis
665 * depuis, et ceux des sous-agents de workflow qu'elle a lancés.
666 */
667export function figuresOf(view: View): { cost: number | null; tokens: number | null; own: SessionRow | null; children: SessionRow[] } {
668  const d = view.discussion
669  const s = view.snapshot
670  const own = (d.id && s?.sessions.find((x) => x.id === d.id)) || null
671  const children = d.id && s ? s.sessions.filter((x) => x.parent === d.id) : []
672  const base = d.detail ? { at: d.detail.fetchedAt, tokens: d.detail.tokens, cost: d.detail.cost } : own && s ? { at: s.fetchedAt, tokens: own.tokens, cost: own.cost } : null
673  const later = d.turns.filter((t) => !base || t.at > base.at).reduce((n, t) => n + t.tokens, 0)
674  const kids = children.reduce((n, x) => n + x.tokens, 0)
675  const tokens = base ? base.tokens + later + kids : d.turns.length ? later : null
676  const cost = d.live?.cost ?? (base ? base.cost + children.reduce((n, x) => n + x.cost, 0) : null)
677  return { cost, tokens, own, children }
678}
679
680const discussionTab = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
681  const { Box, Button } = k
682  const d = view.discussion
683  const live = d.live
684  const fig = figuresOf(view)
685  const tz = ctx.s.tzOffset
686  const metric = ctx.metric
687  const entries = d.detail?.entries ?? []
688  const costs = d.detail ? estimateCosts(entries, d.detail.cost, unitPricesOf(ctx.s.rows)) : []
689  const startedAt = live?.startedAt ?? entries[0]?.[0] ?? fig.own?.first ?? null
690  const elapsed = startedAt ? Math.max(0, view.now - startedAt) : 0
691  const project = fig.own ? groupOf(fig.own.project, ctx.s.projects) : ctx.here
692  const isToday = (ms: number) => dayOf(ms, tz) === ctx.today
693  const when = (ms: number) => (isToday(ms) ? hhmm(ms, tz) : `${fmtDate(dayOf(ms, tz))}${NB}${hhmm(ms, tz)}`)
694  // Rythme : la dernière heure d'appels (l'intensité du moment), et la moyenne depuis le début.
695  const hourAgo = view.now - 3_600_000
696  const lastHour = d.detail && view.now - d.detail.fetchedAt < 30 * 60_000 ? entries.reduce((n, e, i) => (e[0] > hourAgo ? n + (costs[i] ?? 0) : n), 0) : null
697  const average = fig.cost !== null && elapsed > 6 * 60_000 ? fig.cost / (elapsed / 3_600_000) : null
698  const totals = entries.reduce((t, e) => ({ input: t.input + e[2], output: t.output + e[3], cacheWrite: t.cacheWrite + e[4], cacheRead: t.cacheRead + e[5] }), { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 })
699  const cacheShare = totals.input + totals.cacheWrite + totals.cacheRead > 0 ? totals.cacheRead / (totals.input + totals.cacheWrite + totals.cacheRead) : null
700  const turns = live?.turns ?? 0
701
702  const status = d.loading
703    ? line(k, [spinner(k, C.tangerine, view.now, 'détail'), T(k, 'ccusage…', { color: C.tangerine })])
704    : d.detail
705      ? T(k, `détail${NB}${ago(d.detail.fetchedAt, view.now)}`, { dim: true })
706      : null
707  const head = (
708    <Box key="disc-head" flexDirection="column" borderStyle="round" borderColor={C.violet} paddingX={1} marginTop={1}>
709      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
710        <Box flexShrink={1}>{chip(k, `◉${NB}Cette${NB}discussion`, C.violet, { wrap: 'truncate-end' })}</Box>
711        <Box flexDirection="row" flexShrink={0} marginLeft={1} alignItems="center">
712          {status && <Box marginRight={1}>{status}</Box>}
713          <Button key="discussion-refresh" plain={true} label={`↻${NB}Discussion`} onPress={() => act.refreshDiscussion()} />
714        </Box>
715      </Box>
716      <Box marginTop={1}>
717        {T(
718          k,
719          [
720            project ? nameOf(ctx, project) : null,
721            live?.model ? modelName(live.model) : null,
722            turns ? plural(turns, 'tour') : null,
723            startedAt ? `commencée ${ago(startedAt, view.now)}` : null,
724            d.id ? `id ${d.id.slice(0, 8)}` : null,
725          ]
726            .filter(Boolean)
727            .join(SEP),
728          { dim: true, wrap: 'truncate-end' },
729        )}
730      </Box>
731      {T(k, 'Les filtres de période, de machine et de projet ne s’appliquent pas à cet onglet.', { dim: true, italic: true, wrap: 'wrap' })}
732      {d.error && T(k, d.error, { color: C.red, wrap: 'wrap' })}
733    </Box>
734  )
735
736  // La carte du coût : le moteur pour le chiffre, la courbe cumulée de l'estimation par appel.
737  let run = 0
738  const curve = costs.map((c) => (run += c))
739  const sub = [entries.length ? plural(entries.length, 'appel API', 'appels API') : null, turns && fig.cost !== null ? `moy. ${fmtCost(fig.cost / turns)} par tour` : null, live?.cost !== null && live ? 'coût du moteur (/cost)' : 'coût de ccusage']
740    .filter(Boolean)
741    .join(' · ')
742  const value = fig.cost !== null ? fmtCost(fig.cost) : '—'
743  const hero = k.isRich ? (
744    <Box key="disc-hero" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
745      {svgOf(k, heroSvg({ label: 'Coût de cette discussion', value, sub, delta: null, deltaColor: C.green, values: sampleLast(curve, 120), color: MONEY, width: px(ctx.inner) }), `Coût de cette discussion : ${value}. ${sub}`)}
746    </Box>
747  ) : (
748    <Box key="disc-hero" flexDirection="column" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
749      {T(k, 'COÛT DE CETTE DISCUSSION', { dim: true, bold: true })}
750      <Box marginTop={1}>{chip(k, value, MONEY)}</Box>
751      {T(k, sub, { dim: true, wrap: 'truncate-end' })}
752      {curve.length > 1 && <Box marginTop={1}>{columnsChart(k, 'discussion-curve', resample(costs, Math.max(8, ctx.inner - 2)), 3, MONEY)}</Box>}
753    </Box>
754  )
755
756  // Le rythme : la flamme, le chiffre, la jauge et ses niveaux.
757  const shown = lastHour ?? average
758  const rateCard =
759    shown !== null
760      ? card(k, 'disc-rate', 'Rythme', rateHeat(shown).color, T(k, lastHour !== null ? 'dernière heure' : 'moyenne depuis le début', { dim: true }), [
761          <Box flexDirection="row" alignItems="center" marginTop={1}>
762            <Box marginRight={1}>{flame(k, shown, 28)}</Box>
763            {rateText(k, shown, true)}
764            {lastHour !== null && average !== null && <Box marginLeft={2}>{T(k, `moyenne ${fmtCostShort(average)}/h`, { dim: true })}</Box>}
765          </Box>,
766          <Box marginTop={1}>{heatGauge(k, shown, Math.min(ctx.inner, 60))}</Box>,
767          say(k, heatLegend()),
768        ])
769      : null
770
771  const window = live?.window ?? null
772  const tiles: Tile[] = ([
773    fig.tokens !== null ? { key: 'd-tokens', label: 'Tokens', value: fmtTokens(fig.tokens), sub: entries.length ? `entrée ${fmtTokens(totals.input)} · sortie ${fmtTokens(totals.output)}` : 'au dernier relevé', color: BRAND } : null,
774    cacheShare !== null ? { key: 'd-cache', label: 'Lu en cache', value: fmtPct(cacheShare), sub: `lu ${fmtTokens(totals.cacheRead)} · écrit ${fmtTokens(totals.cacheWrite)}`, color: C.teal } : null,
775    live?.contextPercent !== null && live?.contextPercent !== undefined
776      ? { key: 'd-context', label: 'Contexte', value: fmtPct(live.contextPercent / 100), sub: live.contextTokens !== null && window ? `${fmtTokens(live.contextTokens)} sur ${fmtTokens(window)}` : 'de la fenêtre', color: live.contextPercent >= 80 ? C.red : live.contextPercent >= 60 ? C.amber : C.green }
777      : null,
778    startedAt ? { key: 'd-duration', label: 'Durée', value: fmtDuration(elapsed), sub: 'depuis le début', color: C.lilac } : null,
779    entries.length ? { key: 'd-calls', label: 'Appels API', value: fmtInt(entries.length), sub: turns ? `${fmtInt(Math.round(entries.length / turns))} par tour` : 'sur la discussion', color: C.indigo } : null,
780    fig.children.length ? { key: 'd-agents', label: 'Sous-agents', value: fmtInt(fig.children.length), sub: `${fmtCost(fig.children.reduce((n, x) => n + x.cost, 0))} · workflows`, color: C.pink } : null,
781  ] as (Tile | null)[]).filter((x): x is Tile => x !== null)
782
783  if (d.detail && entries.length === 0) {
784    return (
785      <Box key="discussion" flexDirection="column">
786        {head}
787        {hero}
788        {rateCard}
789        {tileGrid(k, tiles)}
790        {card(k, 'disc-none', 'Détail', C.violet, null, [say(k, 'Pas encore d’appel enregistré pour cette discussion : ↻ Discussion après le prochain tour.')])}
791      </Box>
792    )
793  }
794
795  if (!d.detail) {
796    return (
797      <Box key="discussion" flexDirection="column">
798        {head}
799        {hero}
800        {rateCard}
801        {tileGrid(k, tiles)}
802        {card(k, 'disc-empty', 'Détail', C.violet, null, [
803          say(k, d.loading ? 'ccusage lit la transcription de cette discussion…' : 'Le détail (frise, modèles, appels les plus chers) vient de ccusage, sur cette seule discussion.'),
804          !d.loading && (
805            <Box marginTop={1}>
806              <Button key="discussion-detail" variant="primary" label={`▶${NB}Détailler${NB}la${NB}discussion`} onPress={() => act.refreshDiscussion()} />
807            </Box>
808          ),
809        ])}
810      </Box>
811    )
812  }
813
814  // La frise : des seaux de 5 min à 2 j selon la durée, empilés par modèle, et le cumul.
815  const first = entries[0]?.[0] ?? view.now
816  const last = Math.max(entries[entries.length - 1]?.[0] ?? view.now, first + 60_000)
817  const step = bucketMinutes(first, last) * 60_000
818  const start = Math.floor(first / step) * step
819  const n = Math.max(1, Math.floor((last - start) / step) + 1)
820  const models = unique(entries.map((e) => e[1]))
821  const byModel = new Map<string, { cost: number; tokens: number; calls: number }>(models.map((m) => [m, { cost: 0, tokens: 0, calls: 0 }]))
822  const buckets = Array.from({ length: n }, () => new Map<string, number>())
823  entries.forEach((e, i) => {
824    const v = metric === 'cost' ? (costs[i] ?? 0) : entryTokens(e)
825    const b = buckets[Math.min(n - 1, Math.floor((e[0] - start) / step))]!
826    b.set(e[1], (b.get(e[1]) ?? 0) + v)
827    const m = byModel.get(e[1])!
828    m.cost += costs[i] ?? 0
829    m.tokens += entryTokens(e)
830    m.calls += 1
831  })
832  const ranked = [...byModel.entries()].sort((a, b) => b[1].tokens - a[1].tokens)
833  const values = buckets.map((b) => [...b.values()].reduce((s, v) => s + v, 0))
834  let cum = 0
835  const cumulative = values.map((v) => (cum += v))
836  const isLong = last - first > 20 * 3_600_000
837  const label = (i: number) => {
838    const at = start + i * step
839    return isLong ? fmtDate(dayOf(at, tz)) : hhmm(at, tz)
840  }
841  const stepText = step >= 86_400_000 ? `${step / 86_400_000}${NB}j` : step >= 3_600_000 ? `${step / 3_600_000}${NB}h` : `${step / 60_000}${NB}min`
842  const chart = k.isRich
843    ? svgOf(
844        k,
845        stackSvg({
846          buckets: buckets.map((b, i) => ({ label: label(i), parts: ranked.map(([m]) => ({ value: b.get(m) ?? 0, color: ctx.colors[m] ?? C.gray })) })),
847          width: px(ctx.inner),
848          fmt: (v) => fmtMetric(v, metric, true),
849          cumulative: n > 1 ? { values: cumulative, color: C.coral, label: `cumul ${fmtMetric(cum, metric, true)}` } : null,
850        }),
851        `${metric === 'cost' ? 'Coût estimé' : 'Tokens'} par tranche de ${stepText}, empilé par modèle`,
852      )
853    : (() => {
854        const cell = n * 2 <= ctx.inner - 2 ? 2 : 1
855        const firstLabel = label(0)
856        const lastLabel = label(n - 1)
857        const stacks = buckets.map((b) => ranked.map(([m]) => ({ value: b.get(m) ?? 0, color: ctx.colors[m] ?? C.gray })))
858        return (
859          <Box flexDirection="column">
860            {T(k, `max${NB}${fmtMetric(Math.max(...values, 0), metric, true)}`, { dim: true })}
861            {columnsChart(k, 'discussion-chart', values, 6, MONEY, cell, -1, stacks)}
862            {T(k, `${firstLabel}${NB.repeat(Math.max(1, n * cell - firstLabel.length - lastLabel.length))}${lastLabel}`, { dim: true })}
863          </Box>
864        )
865      })()
866
867  const top = entries
868    .map((e, i) => ({ e, cost: costs[i] ?? 0 }))
869    .sort((a, b) => b.cost - a.cost)
870    .slice(0, 5)
871  const maxCost = top[0]?.cost ?? 0
872  const cells = Math.max(6, Math.min(18, ctx.inner - 50))
873
874  return (
875    <Box key="discussion" flexDirection="column">
876      {head}
877      {hero}
878      {rateCard}
879      {tileGrid(k, tiles)}
880      {card(k, 'disc-chart', `${metric === 'cost' ? 'Coût estimé' : 'Tokens'} par tranche de ${stepText}`, toneOf(metric), metricToggle(k, ctx, act), [
881        <Box marginTop={1}>{chart}</Box>,
882        <Box marginTop={1}>{line(k, ranked.map(([m]) => TT(k, [T(k, '●', { color: ctx.colors[m] ?? C.gray }), T(k, `${NB}${modelName(m)}`, { dim: true })])), true)}</Box>,
883      ])}
884      {card(k, 'disc-models', 'Modèles de la discussion', C.violet, null, [
885        ...ranked.map(([m, x], i) => (
886          <Box key={`dm-${m}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
887            <Box width={2} flexShrink={0}>
888              {T(k, '●', { color: ctx.colors[m] ?? C.gray })}
889            </Box>
890            <Box flexGrow={1} flexShrink={1}>
891              {TT(k, [T(k, modelName(m), { bold: true }), T(k, `${NB}${NB}${plural(x.calls, 'appel')}`, { dim: true })], { wrap: 'truncate-end' })}
892            </Box>
893            <Box flexShrink={0} marginLeft={1}>
894              {line(k, [
895                <Box width={9} justifyContent="flex-end">{T(k, fmtTokens(x.tokens), { color: BRAND })}</Box>,
896                <Box width={10} justifyContent="flex-end">{T(k, fmtCost(x.cost), { color: MONEY, bold: true })}</Box>,
897              ])}
898            </Box>
899          </Box>
900        )),
901      ])}
902      {card(k, 'disc-top', 'Appels les plus chers', MONEY, null, [
903        ...top.map(({ e, cost }, i) => (
904          <Box key={`dt-${i}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
905            <Box width={14} flexShrink={0}>
906              {T(k, when(e[0]), { dim: true })}
907            </Box>
908            <Box flexShrink={0} marginRight={1}>
909              {meter(k, [{ value: cost, color: MONEY }], maxCost, cells, fmtCost(cost))}
910            </Box>
911            <Box flexGrow={1} flexShrink={1}>
912              {TT(k, [T(k, fmtCost(cost), { color: MONEY, bold: true }), T(k, `${NB}${NB}${modelName(e[1])}${NB}·${NB}${fmtTokens(entryTokens(e))}`, { dim: true })], { wrap: 'truncate-end' })}
913            </Box>
914          </Box>
915        )),
916      ])}
917      {fig.children.length > 0 &&
918        card(k, 'disc-agents', 'Sous-agents de workflow', C.pink, null, [
919          ...fig.children.slice(0, 8).map((x, i) => (
920            <Box key={`da-${x.id}`} flexDirection="row" marginTop={i === 0 ? 1 : 0}>
921              <Box flexGrow={1} flexShrink={1}>
922                {T(k, `${when(x.first)}${NB}·${NB}${fmtDuration(x.last - x.first)}`, { dim: true, wrap: 'truncate-end' })}
923              </Box>
924              {line(k, [T(k, fmtTokens(x.tokens), { color: BRAND }), T(k, fmtCost(x.cost), { color: MONEY, bold: true })])}
925            </Box>
926          )),
927        ])}
928      {say(k, 'Coût en tête : celui du moteur (/cost). Tokens et détail : ccusage, au dernier ↻ Discussion. Le coût par appel est estimé : ccusage ne donne que le total, réparti selon les tokens de chaque appel et le barème de son modèle.')}
929    </Box>
930  )
931}
932
933// ─── Journée ──────────────────────────────────────────────────────────────
934
935/** Plafond de la liste des discussions du jour ; les suivantes sont additionnées sur une ligne. */
936const DAY_DISCUSSIONS_CAP = 8
937
938/** Une ligne de classement : marque, nom, jauge, valeur, part ; un détail atténué dessous. */
939const shareRow = (k: Kit, o: { key: string; mark: unknown; name: unknown; value: number; max: number; share: number; metric: Metric; cells: number; detail: string | null; isFirst: boolean }): unknown => {
940  const { Box } = k
941  const color = toneOf(o.metric)
942  return (
943    <Box key={o.key} flexDirection="column" marginTop={o.isFirst ? 1 : 0}>
944      <Box flexDirection="row" alignItems="center">
945        <Box width={2} flexShrink={0}>
946          {o.mark}
947        </Box>
948        <Box flexGrow={1} flexShrink={1}>
949          {o.name}
950        </Box>
951        <Box flexShrink={0} marginLeft={1}>
952          {line(k, [
953            meter(k, [{ value: o.value, color }], o.max, o.cells, `${fmtPct(o.share)} du jour`),
954            <Box width={10} justifyContent="flex-end">
955              {T(k, fmtMetric(o.value, o.metric), { color, bold: true })}
956            </Box>,
957            <Box width={6} justifyContent="flex-end">
958              {T(k, fmtPct(o.share), { dim: true })}
959            </Box>,
960          ])}
961        </Box>
962      </Box>
963      {o.detail && <Box marginLeft={2}>{T(k, o.detail, { dim: true, wrap: 'truncate-end' })}</Box>}
964    </Box>
965  )
966}
967
968/**
969 * Le choix du jour : la veille et le lendemain, le jour actif d'avant et d'après (sous les
970 * filtres), et le retour au jour du relevé. Rien avant le premier jour connu ni après le relevé.
971 */
972const dayNav = (k: Kit, choice: ReturnType<typeof dayChoice>, today: string, act: Actions): unknown => {
973  const { Box, Button } = k
974  const { day, first, latest, prevActive, nextActive } = choice
975  // Le jour du relevé s'enregistre comme null : un relevé plus récent le fait suivre.
976  const go = (to: string) => act.setUi({ day: to === latest ? null : to })
977  const dateOf = (d: string) => fmtDate(d, d.slice(0, 4) !== today.slice(0, 4))
978  return (
979    <Box key="day-nav" marginTop={1}>
980      {line(
981        k,
982        [
983          // « veille » toujours en premier : des clics répétés ne tombent jamais sur un autre bouton.
984          day > first && <Button key="day-prev" plain={true} label={`◂${NB}veille`} onPress={() => go(addDays(day, -1))} />,
985          chip(k, day === today ? `aujourd’hui,${NB}${fmtDay(day)}` : fmtDay(day), MONEY),
986          day < latest && <Button key="day-next" plain={true} label={`lendemain${NB}▸`} onPress={() => go(addDays(day, 1))} />,
987          // Le jour actif d'avant ou d'après, quand ce n'est pas déjà la veille ou le lendemain.
988          prevActive && prevActive !== addDays(day, -1) && <Button key="day-prev-active" plain={true} dimColor={true} label={`◂◂${NB}${dateOf(prevActive)}`} onPress={() => go(prevActive)} />,
989          nextActive && nextActive !== addDays(day, 1) && <Button key="day-next-active" plain={true} dimColor={true} label={`${dateOf(nextActive)}${NB}▸▸`} onPress={() => go(nextActive)} />,
990          day < latest && <Button key="day-latest" plain={true} dimColor={true} label={`↺${NB}${latest === today ? 'aujourd’hui' : fmtDay(latest)}`} onPress={() => act.setUi({ day: null })} />,
991        ],
992        true,
993      )}
994    </Box>
995  )
996}
997
998/**
999 * Le récap d'une journée : par défaut celle du relevé, comme le bandeau (aujourd'hui, ou le jour
1000 * d'un relevé plus ancien) ; un jour passé se choisit en tête de l'onglet. Les filtres de poste,
1001 * de projet et de modèle s'appliquent ; la période, non.
1002 */
1003const dayTab = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
1004  const { Box } = k
1005  const s = ctx.s
1006  const tz = s.tzOffset
1007  const metric = ctx.metric
1008  // Tous les jours, sous les filtres de poste, de projet et de modèle : le jour et ses 30 jours de comparaison.
1009  const scope: Filter = { ...ctx.f, from: null }
1010  const all = selectRows(s.rows, scope, s.projects)
1011  const fetchedDay = dayOf(s.fetchedAt, tz)
1012  const choice = dayChoice(ctx.ui.day ?? null, fetchedDay, firstDate(s.rows), activeDates(all))
1013  const day = choice.day
1014  const isToday = day === ctx.today
1015  const dayText = isToday ? 'aujourd’hui' : `le${NB}${fmtDay(day)}`
1016  const rows = all.filter((r) => r.date === day)
1017  const isFiltered = ctx.f.machine !== 'all' || ctx.f.project !== null || ctx.f.model !== null
1018  const notes = [
1019    dayNav(k, choice, ctx.today, act),
1020    // Un relevé d'un jour passé : seulement quand on regarde ce jour-là sans l'avoir choisi.
1021    fetchedDay !== ctx.today && day === fetchedDay && say(k, `Relevé du ${fmtDay(fetchedDay)} : ↻${NB}Actualiser pour la journée en cours.`, { color: C.amber }),
1022    ctx.ui.period !== 'all' && say(k, 'La période ne s’applique pas à cet onglet, qui montre une journée ; les filtres de poste, de projet et de modèle, si.', { dim: true, italic: true }),
1023  ].filter(isShown)
1024  if (rows.length === 0) {
1025    return (
1026      <Box key="day" flexDirection="column">
1027        {notes}
1028        {card(k, 'day-empty', cap(isToday ? 'aujourd’hui' : fmtDay(day)), MONEY, null, [say(k, `Aucune consommation ${dayText}${isFiltered ? ' pour ces filtres' : ''}`.replace(/\.?$/, '.'))])}
1029      </Box>
1030    )
1031  }
1032
1033  const t = sum(rows)
1034  const cmp = compareDay(all, day, 'cost')
1035  const discussions = lacksSessions(ctx) ? [] : discussionsOfDay(selectSessions(s.sessions, scope, s.projects, tz), day, tz)
1036  const agents = discussions.reduce((n, d) => n + d.agents, 0)
1037
1038  // La carte du coût : le chiffre du jour, l'écart avec la veille, la courbe des 30 jours qui finissent ce jour-là.
1039  const label = isToday ? 'Coût d’aujourd’hui' : `Coût du ${fmtDay(day)}`
1040  const delta = cmp.previous > 0 ? (t.cost - cmp.previous) / cmp.previous : null
1041  const deltaText = delta !== null ? `${fmtDelta(delta)} vs la veille` : null
1042  const deltaColor = delta !== null && delta > 0 ? C.coral : C.green
1043  const sub = [`${fmtTokens(t.tokens)} tokens`, discussions.length ? plural(discussions.length, 'discussion') : null, `30${NB}j jusqu’au ${fmtDate(day)}`].filter(Boolean).join(' · ')
1044  const curve = seriesOf(all, 'day', addDays(day, -29), day).map((b) => b.total.cost)
1045  const hero = k.isRich ? (
1046    <Box key="day-hero" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
1047      {svgOf(k, heroSvg({ label, value: fmtCost(t.cost), sub, delta: deltaText, deltaColor, values: curve, color: MONEY, width: px(ctx.inner) }), `${label} : ${fmtCost(t.cost)}${deltaText ? `, ${deltaText}` : ''}. ${sub}`)}
1048    </Box>
1049  ) : (
1050    <Box key="day-hero" flexDirection="column" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
1051      {T(k, label.toUpperCase(), { dim: true, bold: true })}
1052      <Box marginTop={1}>{line(k, [chip(k, fmtCost(t.cost), MONEY), deltaText && T(k, deltaText, { color: deltaColor })])}</Box>
1053      {T(k, sub, { dim: true, wrap: 'truncate-end' })}
1054      <Box marginTop={1}>{columnsChart(k, 'day-curve', curve, 3, MONEY, 1, curve.length - 1)}</Box>
1055    </Box>
1056  )
1057
1058  const lead = modelsOf(rows, 'cost')[0]
1059  // Le cache de /stats a en général un jour de retard : ses messages seulement s'il compte déjà ce jour-là.
1060  const acts = s.activity && isLocalView(ctx) && !ctx.f.project && !ctx.f.model ? (s.activity.days.find((d) => d.date === day) ?? null) : null
1061  const tiles: Tile[] = ([
1062    { key: 'day-tokens', label: 'Tokens', value: fmtTokens(t.tokens), sub: `entrée ${fmtTokens(t.input)} · sortie ${fmtTokens(t.output)}`, color: BRAND },
1063    { key: 'day-cache', label: 'Lu en cache', value: fmtPct(cacheRatio(t)), sub: `lu ${fmtTokens(t.cacheRead)} · écrit ${fmtTokens(t.cacheWrite)}`, color: C.teal },
1064    { key: 'day-previous', label: 'La veille', value: fmtCost(cmp.previous), sub: fmtDay(addDays(day, -1)), color: C.slate },
1065    cmp.average !== null ? { key: 'day-average', label: 'Moyenne sur 30 j', value: fmtCost(cmp.average), sub: `par jour actif · écart ${fmtDelta((t.cost - cmp.average) / cmp.average)}`, color: C.lilac } : null,
1066    cmp.rank !== null && cmp.activeDays > 1 ? { key: 'day-rank', label: 'Rang sur 30 j', value: cmp.rank === 1 ? 'record' : `n°${NB}${cmp.rank}`, sub: `sur ${plural(cmp.activeDays, 'jour actif', 'jours actifs')}`, color: cmp.rank === 1 ? C.coral : MONEY } : null,
1067    !lacksSessions(ctx) ? { key: 'day-discussions', label: 'Discussions', value: fmtInt(discussions.length), sub: agents ? `avec ${plural(agents, 'sous-agent')}` : 'actives ce jour-là', color: C.violet } : null,
1068    lead ? { key: 'day-model', label: 'Modèle principal', value: modelName(lead.model), sub: `${fmtPct(lead.share)} du coût`, color: ctx.colors[lead.model] ?? BRAND } : null,
1069    acts ? { key: 'day-messages', label: 'Messages (ce poste)', value: fmtInt(acts.messages), sub: `${fmtInt(acts.tools)} appels d’outils`, color: C.indigo } : null,
1070  ] as (Tile | null)[]).filter((x): x is Tile => x !== null)
1071
1072  const cells = Math.max(6, Math.min(22, ctx.inner - 46))
1073  const total = valueOf(t, metric)
1074  // Une seule bascule coût / tokens, sur la carte des projets : les autres cartes disent leur mesure.
1075  const unitText = T(k, metric === 'cost' ? 'en coût' : 'en tokens', { dim: true })
1076
1077  // Par poste : seulement quand plusieurs postes ont travaillé ce jour-là.
1078  const machines = ctx.f.machine === 'all' ? s.machines.map((m) => ({ m, total: sum(rows.filter((r) => r.machine === m)) })).filter((x) => valueOf(x.total, metric) > 0) : []
1079  const machinesCard =
1080    machines.length > 1
1081      ? card(k, 'day-machines', 'Par poste', toneOf(metric), unitText, [
1082          ...machines.map((x, i) =>
1083            shareRow(k, {
1084              key: `day-machine-${x.m}`,
1085              mark: T(k, x.m === s.machine ? '●' : '○', { color: x.m === s.machine ? BRAND : C.gray }),
1086              name: TT(k, [T(k, x.m.replace(/ /g, NB), { bold: true }), x.m === s.machine ? T(k, `${NB}${NB}ce poste`, { color: BRAND }) : ''], { wrap: 'truncate-end' }),
1087              value: valueOf(x.total, metric),
1088              max: valueOf(machines[0]!.total, metric),
1089              share: total > 0 ? valueOf(x.total, metric) / total : 0,
1090              metric,
1091              cells,
1092              detail: null,
1093              isFirst: i === 0,
1094            }),
1095          ),
1096          say(k, 'Les autres postes comptent jusqu’à leur dernière publication dans claude-usage.'),
1097        ])
1098      : null
1099
1100  const projects = projectsOf(rows, [], s.projects, metric)
1101  const projectsCard = card(k, 'day-projects', 'Projets du jour', toneOf(metric), metricToggle(k, ctx, act), [
1102    ...projects.slice(0, PROJECTS_CAP).map((p, i) => {
1103      const isHere = p.key === ctx.here
1104      const count = discussions.filter((d) => groupOf(d.project, s.projects) === p.key).length
1105      const detail = [count ? plural(count, 'discussion') : null, elsewhere(ctx, p.byMachine, metric)].filter(Boolean).join(SEP)
1106      return shareRow(k, {
1107        key: `day-project-${p.key}`,
1108        mark: T(k, isHere ? '●' : '○', { color: isHere ? BRAND : C.gray, bold: isHere }),
1109        name: TT(k, [T(k, nameOf(ctx, p.key), { bold: true }), isHere ? T(k, `${NB}${NB}ce projet`, { color: BRAND }) : ''], { wrap: 'truncate-end' }),
1110        value: valueOf(p.total, metric),
1111        max: projects.length ? valueOf(projects[0]!.total, metric) : 0,
1112        share: p.share,
1113        metric,
1114        cells,
1115        detail: detail || null,
1116        isFirst: i === 0,
1117      })
1118    }),
1119    projects.length > PROJECTS_CAP && say(k, `… ${plural(projects.length - PROJECTS_CAP, 'autre projet', 'autres projets')} : ${fmtMetric(projects.slice(PROJECTS_CAP).reduce((n, p) => n + valueOf(p.total, metric), 0), metric)}.`),
1120  ])
1121
1122  // Les discussions : une heure ce jour-là, une date sinon (commencée avant, ou finie après).
1123  const at = (ms: number) => (dayOf(ms, tz) === day ? hhmm(ms, tz) : fmtDate(dayOf(ms, tz)))
1124  const isMulti = ctx.f.machine === 'all' && s.machines.length > 1
1125  const rest = discussions.slice(DAY_DISCUSSIONS_CAP)
1126  const discussionsCard = lacksSessions(ctx)
1127    ? card(k, 'day-discussions', 'Discussions du jour', C.violet, null, [say(k, `${ctx.f.machine} n’a publié que des totaux par jour : pas de détail des discussions.`)])
1128    : discussions.length > 0
1129      ? card(k, 'day-discussions', 'Discussions du jour', C.violet, T(k, plural(discussions.length, 'discussion'), { dim: true }), [
1130          ...discussions.slice(0, DAY_DISCUSSIONS_CAP).map((d, i) => (
1131            <Box key={`day-disc-${d.id}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
1132              <Box width={14} flexShrink={0}>
1133                {T(k, `${at(d.first)}–${at(d.last)}`, { dim: true, wrap: 'truncate-end' })}
1134              </Box>
1135              <Box flexGrow={1} flexShrink={1}>
1136                {TT(
1137                  k,
1138                  [
1139                    T(k, nameOf(ctx, groupOf(d.project, s.projects)), { bold: true }),
1140                    d.id === view.discussion.id ? T(k, `${NB}${NB}celle-ci`, { color: C.violet }) : '',
1141                    isMulti ? T(k, `${NB}·${NB}${d.machine}`, { dim: true }) : '',
1142                    d.agents ? T(k, `${NB}·${NB}+${plural(d.agents, 'sous-agent')}`, { dim: true }) : '',
1143                  ],
1144                  { wrap: 'truncate-end' },
1145                )}
1146              </Box>
1147              <Box flexShrink={0} marginLeft={1}>
1148                {/* Largeurs fixes : modèles, tokens et coûts en colonnes. */}
1149                {line(k, [
1150                  ctx.inner >= 50 && <Box width={4}>{modelDots(k, ctx, d.models)}</Box>,
1151                  ctx.inner >= 70 && <Box width={8} justifyContent="flex-end">{T(k, fmtTokens(d.tokens), { color: BRAND, dim: true })}</Box>,
1152                  <Box width={9} justifyContent="flex-end">{T(k, fmtCost(d.cost), { color: MONEY, bold: true })}</Box>,
1153                ])}
1154              </Box>
1155            </Box>
1156          )),
1157          rest.length > 0 && say(k, `… ${plural(rest.length, 'autre discussion', 'autres discussions')} : ${fmtCost(rest.reduce((n, d) => n + d.cost, 0))}.`),
1158          say(k, 'Coût de chaque discussion : son total, sous-agents et jours précédents compris (ccusage ne le découpe pas par jour). D’où un total qui peut dépasser celui du jour.'),
1159        ])
1160      : null
1161
1162  const models = modelsOf(rows, metric)
1163  const modelsCard = card(k, 'day-models', 'Modèles du jour', toneOf(metric), unitText, [
1164    ...models.map((m, i) =>
1165      shareRow(k, {
1166        key: `day-model-${m.model}`,
1167        mark: T(k, '●', { color: ctx.colors[m.model] ?? C.gray }),
1168        name: TT(k, [T(k, modelName(m.model), { bold: true }), T(k, `${NB}${NB}${metric === 'cost' ? `${fmtTokens(m.total.tokens)}${NB}tokens` : fmtCost(m.total.cost)}`, { dim: true })], { wrap: 'truncate-end' }),
1169        value: valueOf(m.total, metric),
1170        max: models.length ? valueOf(models[0]!.total, metric) : 0,
1171        share: m.share,
1172        metric,
1173        cells,
1174        detail: null,
1175        isFirst: i === 0,
1176      }),
1177    ),
1178  ])
1179
1180  // Les blocs de 5 h du jour : ce poste seul, tous projets (ccusage ne les sépare pas).
1181  const dayBlocks = isLocalView(ctx) ? blocksOfDay(s.blocks, day, tz) : []
1182  const blocksCard =
1183    dayBlocks.length > 0
1184      ? card(k, 'day-blocks', 'Blocs de 5 h', C.tangerine, T(k, `ce${NB}poste, tous projets`, { dim: true }), [
1185          ...dayBlocks.map((b, i) => {
1186            const rate = blockRate(b)
1187            const isActive = b.isActive && b.end > ctx.now
1188            return (
1189              <Box key={`day-block-${b.start}`} marginTop={i === 0 ? 1 : 0}>
1190                {line(
1191                  k,
1192                  [
1193                    T(k, `${hhmm(b.start, tz)}–${hhmm(b.end, tz)}`, isActive ? { color: C.tangerine, bold: true } : { dim: true }),
1194                    T(k, fmtCost(b.cost), { color: MONEY, bold: true }),
1195                    T(k, `${fmtTokens(b.tokens)}${NB}tokens`, { color: BRAND }),
1196                    rate !== null && line(k, [flame(k, rate, 14), rateText(k, rate, true)]),
1197                    isActive && T(k, `en${NB}cours, reste${NB}${fmtDuration(b.end - ctx.now)}`, { color: C.tangerine }),
1198                    isActive && b.projectedCost !== null && T(k, `projection${NB}${fmtCost(b.projectedCost)}`, { dim: true }),
1199                    modelDots(k, ctx, b.models),
1200                  ],
hooks/raster.ts 151 lines
1// Graphiques du terminal en grille de cases (`Raster`) : une case = un caractère, une couleur
2// de texte et une couleur de fond, empaquetés selon `RasterProps` (triplets u32 little-endian
3// `[point de code, avant-plan, fond]`, rangée par rangée, en base64 standard). Une seule
4// feuille par graphique, au lieu d'un Text par série de cases. Fonctions pures : la vue
5// (view.tsx) les pose dans l'arbre, seulement quand la table de la surface a `Raster`.
6
7import { C } from './model.ts'
8import type { BarPart, HeatCell } from './svg.ts'
9
10/** La couleur par défaut du terminal (bit 24 seul). */
11export const DEFAULT = 0x01000000
12/** Les niveaux du calendrier, comme les opacités du SVG (heatmapSvg). */
13export const HEAT_OPACITY = [0.14, 0.32, 0.52, 0.76, 1] as const
14
15const SPACE = 0x20
16const FULL = 0x2588
17/** ▁ à █ : U+2581 à U+2588. */
18const eighth = (fill: number): number => 0x2580 + Math.max(1, Math.min(8, fill))
19
20export type RasterCells = { columns: number; rows: number; cells: string }
21type Cell = readonly [codePoint: number, foreground: number, background: number]
22
23/** `#RRGGBB` en `0x00RRGGBB`. */
24export const rgb = (hex: string): number => {
25  const n = Number.parseInt(hex.replace('#', '').slice(0, 6), 16)
26  return Number.isFinite(n) ? n & 0xffffff : DEFAULT
27}
28
29/**
30 * `color` posé à l'opacité `alpha` sur un fond foncé (C.ink). Le terminal n'a pas de
31 * transparence : les niveaux du SVG deviennent des teintes pleines.
32 */
33export const tint = (hex: string, alpha: number): number => {
34  const a = rgb(C.ink)
35  const b = rgb(hex)
36  const ch = (shift: number): number => {
37    const x = (a >> shift) & 0xff
38    const y = (b >> shift) & 0xff
39    return Math.round(x + (y - x) * alpha) & 0xff
40  }
41  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
42}
43
44const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
45
46/** Base64 standard avec bourrage, écrit à la main (pas de `toBase64` ni de `btoa` supposés). */
47const base64 = (bytes: Uint8Array): string => {
48  let out = ''
49  for (let i = 0; i < bytes.length; i += 3) {
50    const a = bytes[i]!
51    const b = i + 1 < bytes.length ? bytes[i + 1]! : 0
52    const c = i + 2 < bytes.length ? bytes[i + 2]! : 0
53    out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
54    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)]! : '='
55    out += i + 2 < bytes.length ? B64[c & 63]! : '='
56  }
57  return out
58}
59
60/** Empaquette une grille rangée par rangée ; toutes les rangées ont `columns` cases. */
61export const pack = (grid: readonly (readonly Cell[])[]): RasterCells => {
62  const rows = grid.length
63  const columns = grid[0]?.length ?? 0
64  const bytes = new Uint8Array(columns * rows * 12)
65  const view = new DataView(bytes.buffer)
66  let at = 0
67  for (const row of grid) {
68    for (let x = 0; x < columns; x++) {
69      const cell = row[x] ?? [SPACE, DEFAULT, DEFAULT]
70      view.setUint32(at, cell[0], true)
71      view.setUint32(at + 4, cell[1] >>> 0, true)
72      view.setUint32(at + 8, cell[2] >>> 0, true)
73      at += 12
74    }
75  }
76  return { columns, rows, cells: base64(bytes) }
77}
78
79/**
80 * Le calendrier : 7 rangées (lundi en haut), une semaine sur `cell` colonnes (la dernière vide
81 * quand `cell` > 1, comme les glyphes), chaque jour une case pleine à la teinte de son niveau
82 * (niveau 0 en gris). Les jours hors période restent vides.
83 */
84export const heatCells = (weeks: readonly HeatCell[][], color: string, cell: number): RasterCells => {
85  const shades = HEAT_OPACITY.map((o, i) => (i === 0 ? tint(C.gray, 0.45) : tint(color, o)))
86  const grid: Cell[][] = []
87  for (let d = 0; d < 7; d++) {
88    const row: Cell[] = []
89    for (const week of weeks) {
90      const day = week[d]
91      for (let j = 0; j < cell; j++) row.push(day && (j < cell - 1 || cell === 1) ? [FULL, shades[day.level] ?? shades[4]!, DEFAULT] : [SPACE, DEFAULT, DEFAULT])
92    }
93    grid.push(row)
94  }
95  return pack(grid)
96}
97
98/** La légende du calendrier : les cinq teintes, une case chacune. */
99export const heatLegendCells = (color: string): RasterCells =>
100  pack([HEAT_OPACITY.map((o, i) => [FULL, i === 0 ? tint(C.gray, 0.45) : tint(color, o), DEFAULT] as Cell)])
101
102/**
103 * Colonnes sur `height` rangées, en huitièmes de case ; `cell` cases par colonne (la dernière
104 * vide quand `cell` > 1). Avec `stacks` (les parts de chaque colonne, du bas vers le haut),
105 * une case pleine coupée par deux parts prend la part du bas en avant-plan et celle du haut en
106 * fond : la frontière entre modèles tombe au huitième près.
107 */
108export const columnsCells = (
109  values: readonly number[],
110  height: number,
111  color: string,
112  cell = 1,
113  highlight = -1,
114  stacks: readonly (readonly BarPart[])[] | null = null,
115): RasterCells => {
116  const max = Math.max(...values, 1e-9)
117  const tall = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * height * 8)) : 0))
118  // La couleur du huitième `at8` (compté depuis le bas) de la colonne `i`.
119  const colorAt = (i: number, at8: number): string => {
120    const parts = stacks?.[i]
121    const all = values[i] ?? 0
122    if (!parts || all <= 0) return i === highlight ? C.coral : color
123    let top = 0
124    for (const p of parts) {
125      top += (Math.max(0, p.value) / all) * (tall[i] ?? 0)
126      if (at8 < top) return p.color
127    }
128    return parts[parts.length - 1]?.color ?? color
129  }
130  const grid: Cell[][] = []
131  for (let r = height - 1; r >= 0; r--) {
132    const row: Cell[] = []
133    tall.forEach((h8, i) => {
134      const fill = Math.max(0, Math.min(8, h8 - r * 8))
135      let glyph: Cell = [SPACE, DEFAULT, DEFAULT]
136      if (fill === 8) {
137        // Case pleine : une frontière entre deux parts devient un huitième bicolore.
138        const low = colorAt(i, r * 8 + 0.5)
139        let cut = 0
140        for (let j = 1; j < 8 && !cut; j++) if (colorAt(i, r * 8 + j + 0.5) !== low) cut = j
141        glyph = cut ? [eighth(cut), rgb(low), rgb(colorAt(i, r * 8 + cut + 0.5))] : [FULL, rgb(low), DEFAULT]
142      } else if (fill > 0) {
143        glyph = [eighth(fill), rgb(colorAt(i, r * 8 + fill / 2)), DEFAULT]
144      }
145      for (let j = 0; j < cell; j++) row.push(j < cell - 1 || cell === 1 ? glyph : [SPACE, DEFAULT, DEFAULT])
146    })
147    grid.push(row)
148  }
149  return pack(grid)
150}
151
hooks/svg.ts 410 lines
1// Graphiques SVG des surfaces distantes (desktop, VS Code, mobile). La base (échappement,
2// barre segmentée, anneau, indicateur) est copiée de git-board (hooks/svg.ts) ; les
3// graphiques de statistiques suivent.
4//
5// - Les dessins sont des images (jamais `isInteractive`, qui dessine dans un cadre à fond
6//   blanc rechargé à chaque redessin).
7// - Rien qui change chaque seconde dans un SVG : un SVG identique n'est pas rechargé.
8// - Couleurs mi-tons et gris neutre, opacités plutôt que teintes claires ou foncées :
9//   lisibles sur fond clair comme sombre.
10// - Chaque SVG reste sous 131 072 caractères et compte dans les 100 000 du panneau : valeurs
11//   arrondies au dixième, pas d'attribut inutile.
12
13const FONT = `font-family="'Segoe UI', system-ui, -apple-system, sans-serif"`
14const MUTED = '#8B8B93'
15const WHITE = '#FFFFFF'
16
17export const esc = (text: string): string =>
18  text.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c] ?? c)
19
20const svg = (width: number, height: number, body: string): string =>
21  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
22
23const r1 = (n: number): number => Math.round(n * 10) / 10
24
25export type Drawn = { source: string; width: number; height: number }
26
27/** Un plafond rond pour un axe : 1, 2, 2,5 ou 5 fois une puissance de dix. */
28export function niceMax(value: number): number {
29  if (!(value > 0)) return 1
30  const pow = 10 ** Math.floor(Math.log10(value))
31  for (const m of [1, 2, 2.5, 5, 10]) if (m * pow >= value) return m * pow
32  return 10 * pow
33}
34
35// ─── Base copiée de git-board ───────────────────────────────────────────────
36
37export type BarPart = { value: number; color: string }
38
39/** Barre segmentée arrondie. */
40export const barSvg = (parts: readonly BarPart[], total: number, width: number, height = 9): Drawn => {
41  const w = Math.max(40, Math.round(width))
42  const sum = Math.max(total, parts.reduce((n, p) => n + p.value, 0), 1e-9)
43  let x = 0
44  const segs: string[] = []
45  for (const part of parts) {
46    if (part.value <= 0) continue
47    const sw = (part.value / sum) * w
48    segs.push(`<rect x="${r1(x)}" width="${r1(Math.max(1, sw))}" height="${height}" fill="${part.color}"/>`)
49    x += sw
50  }
51  const body =
52    `<defs><clipPath id="b"><rect width="${w}" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
53    `<g clip-path="url(#b)"><rect width="${w}" height="${height}" fill="${MUTED}" fill-opacity="0.2"/>${segs.join('')}</g>`
54  return { source: svg(w, height, body), width: w, height }
55}
56
57/** Anneau de progression, le pourcentage au centre. */
58export const ringSvg = (ratio: number, color: string, size = 56, label?: string): Drawn => {
59  const r = size / 2 - 6
60  const c = 2 * Math.PI * r
61  const value = Math.min(1, Math.max(0, ratio))
62  const mid = size / 2
63  const body =
64    `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="6"/>` +
65    (value > 0
66      ? `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${color}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${r1(c * value)} ${r1(c)}" transform="rotate(-90 ${mid} ${mid})"/>`
67      : '') +
68    `<text x="${mid}" y="${mid + 4.5}" text-anchor="middle" ${FONT} font-size="13" font-weight="700" fill="${color}">${esc(label ?? `${Math.round(value * 100)}%`)}</text>`
69  return { source: svg(size, size, body), width: size, height: size }
70}
71
72/** Un arc qui tourne : un relevé en cours. */
73export const spinnerSvg = (color: string, size = 12): Drawn => {
74  const m = size / 2
75  const r = m - 2
76  const body =
77    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2"/>` +
78    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${r1(r * 2)} ${r1(r * 5)}"><animateTransform attributeName="transform" type="rotate" from="0 ${m} ${m}" to="360 ${m} ${m}" dur="0.9s" repeatCount="indefinite"/></circle>`
79  return { source: svg(size, size, body), width: size, height: size }
80}
81
82// ─── Courbes ────────────────────────────────────────────────────────────────
83
84/** Les points d'une courbe dans un cadre ; une valeur seule devient un plateau. */
85const points = (values: readonly number[], x0: number, x1: number, y0: number, y1: number): [number, number][] => {
86  const max = Math.max(...values, 1e-9)
87  const n = values.length
88  return values.map((v, i) => [r1(n > 1 ? x0 + (i * (x1 - x0)) / (n - 1) : (x0 + x1) / 2), r1(y1 - (v / max) * (y1 - y0))])
89}
90
91const area = (pts: readonly [number, number][], base: number, color: string, id: string): string => {
92  if (pts.length === 0) return ''
93  const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('')
94  const first = pts[0]!
95  const last = pts[pts.length - 1]!
96  return (
97    `<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity="0.38"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
98    `<path d="${line}L${last[0]} ${base}L${first[0]} ${base}Z" fill="url(#${id})"/>` +
99    `<path d="${line}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
100    `<circle cx="${last[0]}" cy="${last[1]}" r="3.2" fill="${color}"/>`
101  )
102}
103
104/** Courbe compacte d'une tuile. */
105export const sparkSvg = (values: readonly number[], color: string, width: number, height = 26): Drawn => {
106  const w = Math.max(40, Math.round(width))
107  const body = values.length > 1 ? area(points(values, 3, w - 4, 4, height - 2), height, color, 's') : ''
108  return { source: svg(w, height, body), width: w, height }
109}
110
111/**
112 * La carte de tête : un libellé, un grand chiffre, sa variation et une ligne de détail à
113 * gauche ; la courbe de la période à droite, quand la place le permet.
114 */
115export function heroSvg(o: { label: string; value: string; sub: string; delta: string | null; deltaColor: string; values: readonly number[]; color: string; width: number }): Drawn {
116  const w = Math.max(220, Math.round(o.width))
117  const h = 86
118  const valueW = o.value.length * 17.5
119  const hasCurve = w >= 380 && o.values.length > 1
120  const text =
121    `<text x="2" y="16" ${FONT} font-size="10.5" font-weight="700" letter-spacing="0.8" fill="${MUTED}">${esc(o.label.toUpperCase())}</text>` +
122    `<text x="0" y="52" ${FONT} font-size="32" font-weight="700" fill="${o.color}">${esc(o.value)}</text>` +
123    (o.delta ? `<text x="${r1(valueW + 10)}" y="50" ${FONT} font-size="12.5" font-weight="700" fill="${o.deltaColor}">${esc(o.delta)}</text>` : '') +
124    `<text x="2" y="76" ${FONT} font-size="11.5" fill="${MUTED}">${esc(o.sub)}</text>`
125  const curve = hasCurve ? area(points(o.values, w * 0.55, w - 6, 10, 70), 78, o.color, 'h') : ''
126  return { source: svg(w, h, curve + text), width: w, height: h }
127}
128
129// ─── Calendrier d'activité ──────────────────────────────────────────────────
130
131export type HeatCell = { date: string; level: number; isToday: boolean } | null
132
133/**
134 * Le calendrier : une colonne par semaine (lundi en haut), une case par jour, cinq
135 * niveaux d'intensité. `weeks[i][d]` est null hors de la période.
136 */
137export function heatmapSvg(weeks: readonly HeatCell[][], months: readonly { index: number; label: string }[], color: string, width: number): Drawn {
138  const n = Math.max(1, weeks.length)
139  const left = 22
140  const top = 16
141  const step = Math.max(7, Math.min(16, Math.floor((Math.max(160, width) - left - 4) / n)))
142  const size = step - 3
143  const w = left + n * step + 2
144  const h = top + 7 * step + 22
145  const OPACITY = [0.14, 0.32, 0.52, 0.76, 1]
146  const cells: string[] = []
147  weeks.forEach((week, i) => {
148    week.forEach((cell, d) => {
149      if (!cell) return
150      const x = left + i * step
151      const y = top + d * step
152      const fill = cell.level === 0 ? MUTED : color
153      cells.push(
154        `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2.5" fill="${fill}" fill-opacity="${OPACITY[cell.level] ?? 1}"${cell.isToday ? ` stroke="${color}" stroke-width="1.6"` : ''}/>`,
155      )
156    })
157  })
158  const labels = months
159    .filter((m, i, all) => i === 0 || m.index - all[i - 1]!.index >= 3)
160    .map((m) => `<text x="${left + m.index * step}" y="11" ${FONT} font-size="10" fill="${MUTED}">${esc(m.label)}</text>`)
161  const days = ['L', '', 'M', '', 'V', '', '']
162    .map((d, i) => (d ? `<text x="2" y="${top + i * step + size - 1}" ${FONT} font-size="9.5" fill="${MUTED}">${d}</text>` : ''))
163    .join('')
164  const ly = top + 7 * step + 8
165  const lx = w - 5 * (size + 3) - 44
166  const legend =
167    `<text x="${lx - 4}" y="${ly + size - 1}" text-anchor="end" ${FONT} font-size="9.5" fill="${MUTED}">Moins</text>` +
168    OPACITY.map((o, i) => `<rect x="${lx + i * (size + 3)}" y="${ly}" width="${size}" height="${size}" rx="2.5" fill="${i === 0 ? MUTED : color}" fill-opacity="${o}"/>`).join('') +
169    `<text x="${lx + 5 * (size + 3) + 2}" y="${ly + size - 1}" ${FONT} font-size="9.5" fill="${MUTED}">Plus</text>`
170  return { source: svg(w, h, labels.join('') + days + cells.join('') + legend), width: w, height: h }
171}
172
173// ─── Barres empilées ────────────────────────────────────────────────────────
174
175export type StackBucket = { label: string; parts: readonly BarPart[] }
176
177/**
178 * Barres empilées (une couleur par modèle) avec un axe gradué et, en option, la courbe du
179 * cumul sur sa propre échelle, sa valeur finale au bout.
180 */
181export function stackSvg(o: {
182  buckets: readonly StackBucket[]
183  width: number
184  height?: number
185  fmt: (v: number) => string
186  cumulative?: { values: readonly number[]; color: string; label: string } | null
187  /** Plafond imposé de l'axe (1 pour des parts) ; absent, un plafond rond au-dessus du maximum. */
188  max?: number
189}): Drawn {
190  const w = Math.max(240, Math.round(o.width))
191  const h = o.height ?? 168
192  const left = 44
193  const right = o.cumulative ? 58 : 8
194  const top = 10
195  const bottom = 18
196  const pw = w - left - right
197  const ph = h - top - bottom
198  const n = Math.max(1, o.buckets.length)
199  const totals = o.buckets.map((b) => b.parts.reduce((s, p) => s + Math.max(0, p.value), 0))
200  const max = o.max ?? niceMax(Math.max(...totals, 0))
201  const slot = pw / n
202  const bw = Math.max(1, Math.min(22, slot * 0.72))
203  const parts: string[] = []
204  // Graduations : 0, la moitié et le plafond.
205  for (const f of [0, 0.5, 1]) {
206    const y = r1(top + ph - f * ph)
207    parts.push(`<line x1="${left}" y1="${y}" x2="${left + pw}" y2="${y}" stroke="${MUTED}" stroke-opacity="${f === 0 ? 0.45 : 0.22}" stroke-dasharray="${f === 0 ? '' : '3 4'}"/>`)
208    parts.push(`<text x="${left - 6}" y="${r1(y + 3.5)}" text-anchor="end" ${FONT} font-size="10" fill="${MUTED}">${esc(o.fmt(max * f))}</text>`)
209  }
210  o.buckets.forEach((b, i) => {
211    let y = top + ph
212    const x = r1(left + i * slot + (slot - bw) / 2)
213    for (const p of b.parts) {
214      if (p.value <= 0) continue
215      const bh = Math.min(y - top, (p.value / max) * ph)
216      y -= bh
217      parts.push(`<rect x="${x}" y="${r1(y)}" width="${r1(bw)}" height="${r1(Math.max(0.6, bh))}" fill="${p.color}"/>`)
218    }
219  })
220  // Libellés de l'axe des x : six au plus, le premier et le dernier compris.
221  const every = Math.max(1, Math.ceil(n / 6))
222  o.buckets.forEach((b, i) => {
223    if (i % every !== 0 && i !== n - 1) return
224    if (i !== n - 1 && n - 1 - i < every / 2) return
225    const x = r1(left + i * slot + slot / 2)
226    const anchor = i === 0 && slot < 30 ? 'start' : i === n - 1 && slot < 30 ? 'end' : 'middle'
227    parts.push(`<text x="${anchor === 'start' ? left : anchor === 'end' ? left + pw : x}" y="${h - 4}" text-anchor="${anchor}" ${FONT} font-size="10" fill="${MUTED}">${esc(b.label)}</text>`)
228  })
229  if (o.cumulative && o.cumulative.values.length > 1) {
230    const vals = o.cumulative.values
231    const cmax = Math.max(...vals, 1e-9)
232    const pts = vals.map((v, i) => [r1(left + i * slot + slot / 2), r1(top + ph - (v / cmax) * ph)] as const)
233    const last = pts[pts.length - 1]!
234    parts.push(`<polyline points="${pts.map((p) => p.join(',')).join(' ')}" fill="none" stroke="${o.cumulative.color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>`)
235    parts.push(`<circle cx="${last[0]}" cy="${last[1]}" r="3.4" fill="${o.cumulative.color}"/>`)
236    parts.push(`<text x="${r1(last[0] + 7)}" y="${r1(Math.max(top + 9, last[1] + 4))}" ${FONT} font-size="10.5" font-weight="700" fill="${o.cumulative.color}">${esc(o.cumulative.label)}</text>`)
237  }
238  return { source: svg(w, h, parts.join('')), width: w, height: h }
239}
240
241// ─── Anneau de répartition ──────────────────────────────────────────────────
242
243/** Un anneau en parts (un modèle par part), un chiffre et un mot au centre. */
244export function donutSvg(parts: readonly BarPart[], size: number, center: string, sub: string, color: string): Drawn {
245  const s = Math.max(90, Math.round(size))
246  const mid = s / 2
247  const r = mid - 10
248  const c = 2 * Math.PI * r
249  const total = parts.reduce((n, p) => n + Math.max(0, p.value), 0)
250  const gap = parts.filter((p) => p.value > 0).length > 1 ? 1.6 : 0
251  let at = 0
252  const arcs: string[] = [`<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.18" stroke-width="13"/>`]
253  if (total > 0) {
254    for (const p of parts) {
255      if (p.value <= 0) continue
256      const len = (p.value / total) * c
257      const shown = Math.max(0.8, len - gap)
258      arcs.push(
259        `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${p.color}" stroke-width="13" stroke-dasharray="${r1(shown)} ${r1(c)}" stroke-dashoffset="${r1(-at)}" transform="rotate(-90 ${mid} ${mid})"/>`,
260      )
261      at += len
262    }
263  }
264  const body =
265    arcs.join('') +
266    `<text x="${mid}" y="${mid + 2}" text-anchor="middle" ${FONT} font-size="${center.length > 7 ? 14 : 17}" font-weight="700" fill="${color}">${esc(center)}</text>` +
267    `<text x="${mid}" y="${mid + 17}" text-anchor="middle" ${FONT} font-size="10" fill="${MUTED}">${esc(sub)}</text>`
268  return { source: svg(s, s, body), width: s, height: s }
269}
270
271// ─── Colonnes ───────────────────────────────────────────────────────────────
272
273/**
274 * Des colonnes simples (heures, jours de la semaine, blocs de 5 h) : la plus haute porte sa
275 * valeur et sa couleur pleine, les autres sont adoucies ; `labels` vides sautés.
276 */
277export function columnsSvg(o: { values: readonly number[]; labels: readonly string[]; color: string; width: number; height?: number; fmt: (v: number) => string; highlight?: number }): Drawn {
278  const w = Math.max(160, Math.round(o.width))
279  const h = o.height ?? 112
280  const top = 16
281  const bottom = 16
282  const ph = h - top - bottom
283  const n = Math.max(1, o.values.length)
284  const slot = (w - 4) / n
285  const bw = Math.max(2, Math.min(34, slot * 0.66))
286  const max = Math.max(...o.values, 1e-9)
287  const peak = o.highlight ?? o.values.indexOf(Math.max(...o.values))
288  const parts: string[] = [`<line x1="2" y1="${top + ph + 0.5}" x2="${w - 2}" y2="${top + ph + 0.5}" stroke="${MUTED}" stroke-opacity="0.4"/>`]
289  o.values.forEach((v, i) => {
290    const bh = v > 0 ? Math.max(2, (v / max) * ph) : 0
291    const x = r1(2 + i * slot + (slot - bw) / 2)
292    const isPeak = i === peak && v > 0
293    if (bh > 0) parts.push(`<rect x="${x}" y="${r1(top + ph - bh)}" width="${r1(bw)}" height="${r1(bh)}" rx="${r1(Math.min(3, bw / 3))}" fill="${o.color}" fill-opacity="${isPeak ? 1 : 0.5}"/>`)
294    if (isPeak) parts.push(`<text x="${r1(x + bw / 2)}" y="${r1(top + ph - bh - 4)}" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${o.color}">${esc(o.fmt(v))}</text>`)
295    const label = o.labels[i]
296    if (label) parts.push(`<text x="${r1(x + bw / 2)}" y="${h - 3}" text-anchor="middle" ${FONT} font-size="10" fill="${isPeak ? o.color : MUTED}"${isPeak ? ' font-weight="700"' : ''}>${esc(label)}</text>`)
297  })
298  return { source: svg(w, h, parts.join('')), width: w, height: h }
299}
300
301// ─── Bloc de 5 h ────────────────────────────────────────────────────────────
302
303/**
304 * Le bloc de facturation en cours : la piste de 5 h, la part écoulée pleine, la fin projetée
305 * en pointillé, l'heure de début, de fin et « maintenant ».
306 */
307export function blockSvg(o: { elapsed: number; startLabel: string; endLabel: string; nowLabel: string; color: string; width: number }): Drawn {
308  const w = Math.max(200, Math.round(o.width))
309  const h = 38
310  const x0 = 4
311  const x1 = w - 4
312  const ratio = Math.min(1, Math.max(0, o.elapsed))
313  const xn = r1(x0 + ratio * (x1 - x0))
314  const body =
315    `<rect x="${x0}" y="6" width="${x1 - x0}" height="9" rx="4.5" fill="${MUTED}" fill-opacity="0.2"/>` +
316    `<rect x="${x0}" y="6" width="${r1(Math.max(4.5, xn - x0))}" height="9" rx="4.5" fill="${o.color}"/>` +
317    `<line x1="${xn}" y1="2" x2="${xn}" y2="19" stroke="${o.color}" stroke-width="2" stroke-linecap="round"/>` +
318    `<text x="${x0}" y="33" ${FONT} font-size="10" fill="${MUTED}">${esc(o.startLabel)}</text>` +
319    `<text x="${x1}" y="33" text-anchor="end" ${FONT} font-size="10" fill="${MUTED}">${esc(o.endLabel)}</text>` +
320    (ratio > 0.12 && ratio < 0.88 ? `<text x="${xn}" y="33" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${o.color}">${esc(o.nowLabel)}</text>` : '')
321  return { source: svg(w, h, body), width: w, height: h }
322}
323
324// ─── Rythme horaire : flamme et jauge ───────────────────────────────────────
325
326/** Les teintes de chaque niveau, du bout de la flamme à sa base. */
327const FIRE: readonly { tip: string; base: string; scale: number }[] = [
328  { tip: MUTED, base: MUTED, scale: 0.62 },
329  { tip: '#F7D774', base: '#D9A520', scale: 0.72 },
330  { tip: '#FFC46B', base: '#E8913A', scale: 0.84 },
331  { tip: '#FFB07A', base: '#EF5B3C', scale: 0.94 },
332  { tip: '#FFE27A', base: '#E5383B', scale: 1 },
333]
334
335// Une flamme dans une boîte de 24 × 30 : le corps, puis son cœur clair.
336const FLAME = 'M12 1C13.6 6 19.5 9.4 19.5 17.5A7.5 7.5 0 0 1 4.5 17.5C4.5 12.8 7.6 10.6 8.8 7C9.9 9.6 10.9 10.3 11.5 11.2C12.6 8.1 12.6 4.6 12 1Z'
337const CORE = 'M12 13.2C13.1 15.7 15.6 16.8 15.6 19.8A3.6 3.6 0 0 1 8.4 19.8C8.4 17.8 9.9 16.7 10.4 15.2C10.9 16.2 11.4 16.5 11.8 16.8C12.2 15.6 12.3 14.4 12 13.2Z'
338
339const flameAt = (x: number, scale: number, level: number, id: string): string => {
340  const f = FIRE[level] ?? FIRE[0]!
341  const t = `translate(${r1(x + 12 * (1 - scale))} ${r1(30 * (1 - scale))}) scale(${scale})`
342  if (level === 0) return `<path d="${FLAME}" transform="${t}" fill="none" stroke="${MUTED}" stroke-opacity="0.7" stroke-width="1.8" stroke-linejoin="round"/>`
343  return (
344    `<path d="${FLAME}" transform="${t}" fill="url(#${id})"/>` +
345    (level >= 2 ? `<path d="${CORE}" transform="${t}" fill="#FFF3C4" fill-opacity="${level >= 3 ? 0.9 : 0.65}"/>` : '')
346  )
347}
348
349/**
350 * La flamme du rythme horaire : éteinte (contour gris) au calme, puis plus haute et plus rouge à
351 * chaque niveau ; « en feu », un halo et deux flammèches de part et d'autre.
352 */
353export function flameSvg(level: number, height = 16): Drawn {
354  const lv = Math.max(0, Math.min(4, Math.round(level)))
355  const f = FIRE[lv]!
356  const isWide = lv === 4
357  const vw = isWide ? 40 : 24
358  const grad = `<defs><linearGradient id="fl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${f.tip}"/><stop offset="1" stop-color="${f.base}"/></linearGradient></defs>`
359  const body = isWide
360    ? `<path d="${FLAME}" transform="translate(5 -2.2) scale(1.25)" fill="${f.base}" fill-opacity="0.22"/>` + flameAt(-1, 0.6, 3, 'fl') + flameAt(17, 0.6, 3, 'fl') + flameAt(8, 1, 4, 'fl')
361    : flameAt(0, f.scale, lv, 'fl')
362  const w = Math.round((height * vw) / 30)
363  return { source: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${vw} 30">${grad}${body}</svg>`, width: w, height }
364}
365
366/**
367 * La jauge du rythme : cinq zones de même largeur (calme → en feu) en dégradé, les seuils
368 * marqués, une flèche et la valeur au rythme courant.
369 */
370export function heatGaugeSvg(o: { perHour: number; thresholds: readonly number[]; colors: readonly string[]; width: number }): Drawn {
371  const w = Math.max(200, Math.round(o.width))
372  const h = 40
373  const x0 = 4
374  const x1 = w - 4
375  const zone = (x1 - x0) / 5
376  const edges = [0, ...o.thresholds, (o.thresholds[o.thresholds.length - 1] ?? 32) * 2]
377  // Position par zones : chaque niveau a la même place, quelle que soit sa plage en $/h.
378  let at = x1
379  for (let i = 0; i < 5; i++) {
380    const lo = edges[i]!
381    const hi = edges[i + 1]!
382    if (o.perHour < hi) {
383      at = x0 + zone * (i + Math.max(0, o.perHour - lo) / Math.max(1e-9, hi - lo))
384      break
385    }
386  }
387  const stops = o.colors.map((c, i) => `<stop offset="${r1((i + 0.5) / o.colors.length)}" stop-color="${c}"/>`).join('')
388  const ticks = o.thresholds
389    .map((t, i) => {
390      const x = r1(x0 + zone * (i + 1))
391      return `<line x1="${x}" y1="14" x2="${x}" y2="24" stroke="${MUTED}" stroke-opacity="0.6"/><text x="${x}" y="36" text-anchor="middle" ${FONT} font-size="9.5" fill="${MUTED}">${t}</text>`
392    })
393    .join('')
394  const ax = r1(at)
395  const body =
396    // Le dégradé sur toute la piste : une jauge peu remplie reste verte au lieu de résumer toutes les couleurs.
397    `<defs><linearGradient id="hg" gradientUnits="userSpaceOnUse" x1="${x0}" y1="0" x2="${x1}" y2="0">${stops}</linearGradient><clipPath id="hc"><rect x="${x0}" y="15" width="${x1 - x0}" height="8" rx="4"/></clipPath></defs>` +
398    `<rect x="${x0}" y="15" width="${x1 - x0}" height="8" rx="4" fill="url(#hg)" fill-opacity="0.28"/>` +
399    `<rect x="${x0}" y="15" width="${r1(Math.max(4, ax - x0))}" height="8" fill="url(#hg)" clip-path="url(#hc)"/>` +
400    ticks +
401    `<path d="M${ax} 13 l-4.5 -6 h9 z" fill="${o.colors[Math.min(o.colors.length - 1, Math.floor((at - x0) / zone))] ?? MUTED}"/>`
402  return { source: svg(w, h, body), width: w, height: h }
403}
404
405/** Point d'état plein. */
406export const dotSvg = (color: string, size = 10): Drawn => {
407  const m = size / 2
408  return { source: svg(size, size, `<circle cx="${m}" cy="${m}" r="${m - 1}" fill="${color}"/><circle cx="${m}" cy="${m}" r="${r1(m / 3)}" fill="${WHITE}" fill-opacity="0.35"/>`), width: size, height: size }
409}
410
types/index.d.ts 203 lines
1// Contrat de l'état de stats-board : ce que l'hôte garde pour la session (`$.state`). Le
2// dernier relevé (`Snapshot`) est aussi gardé dans `$.store`, d'une session à l'autre.
3
4/** Une ligne de consommation : un jour, un projet, un modèle (sommes de ccusage). */
5export type UsageRow = {
6  /** Jour local, AAAA-MM-JJ. */
7  date: string
8  /** Dossier du projet tel que Claude Code l'encode (`C--Users-moi-projet`). */
9  project: string
10  /** Le poste qui l'a relevée : son nom réglé (Portable, Mac, Serveur…), ou « Ce poste » sans nom. */
11  machine: string
12  /** Modèle tel que ccusage le nomme (`claude-opus-5-5`). */
13  model: string
14  input: number
15  output: number
16  cacheWrite: number
17  cacheRead: number
18  /** Coût équivalent API, en dollars. */
19  cost: number
20}
21
22/** Une conversation Claude Code d'un poste (`ccusage claude session`). */
23export type SessionRow = {
24  id: string
25  /** Le poste où elle a eu lieu. */
26  machine: string
27  project: string
28  /** Sous-agent de workflow : la discussion qui l'a lancé (son dossier est `<projet>\<parent>\subagents\…`). */
29  parent: string | null
30  /** Première et dernière activité, en ms. */
31  first: number
32  last: number
33  cost: number
34  tokens: number
35  output: number
36  models: string[]
37}
38
39/** Un bloc de facturation de 5 h (`ccusage claude blocks`), sans les creux. */
40export type BlockRow = {
41  start: number
42  end: number
43  /** Dernière activité du bloc, en ms. */
44  lastAt: number
45  isActive: boolean
46  cost: number
47  tokens: number
48  models: string[]
49  /** Bloc en cours : rythme et projection de ccusage ; null ailleurs. */
50  costPerHour: number | null
51  projectedCost: number | null
52  remainingMinutes: number | null
53}
54
55export type ActivityDay = { date: string; messages: number; sessions: number; tools: number }
56
57/** Le cache de /stats de Claude Code (`stats-cache.json`), pour ce poste seulement. */
58export type Activity = {
59  /** Dernier jour compté : le cache a en général un jour de retard. */
60  through: string | null
61  days: ActivityDay[]
62  /** Sessions commencées par heure locale, de 0 à 23. */
63  hours: number[]
64  sessions: number
65  messages: number
66  longest: { ms: number; messages: number; at: string } | null
67  since: string | null
68}
69
70/**
71 * Le nom lisible d'un projet et son dossier, lus dans les transcriptions de Claude Code ; `label`, son nom
72 * d'affichage choisi dans `names.json` du dépôt partagé (jamais publié : `name` sert au rapprochement des postes).
73 */
74export type ProjectInfo = { name: string; path: string | null; label?: string }
75
76export type Snapshot = {
77  /** Format du relevé : un relevé gardé d'un autre format est écarté et refait. */
78  schema: number
79  /** Fin du dernier relevé, en ms. */
80  fetchedAt: number
81  /** Décalage horaire local, en minutes (comme `Date#getTimezoneOffset`). */
82  tzOffset: number
83  rows: UsageRow[]
84  sessions: SessionRow[]
85  blocks: BlockRow[]
86  activity: Activity | null
87  projects: Record<string, ProjectInfo>
88  /** Modèles sans prix connu de ccusage : comptés 0 $. */
89  unpriced: string[]
90  /** Prix embarqués de ccusage : le barème en ligne n'a pas pu être lu. */
91  isOffline: boolean
92  /** Le nom de ce poste (`claude-usage/.machine`), ou « Ce poste » sans nom réglé. */
93  machine: string
94  /** Les postes présents dans le relevé, celui-ci d'abord. */
95  machines: string[]
96  /** Les postes qui n'ont publié aucune session (l'ancien serveur : des totaux par jour seulement). */
97  sessionless: string[]
98  /** Version de ccusage qui a fait le relevé. */
99  version: string | null
100  /** Jours gardés par le mod alors que Claude Code a effacé leurs journaux. */
101  keptDays: number
102  warnings: string[]
103}
104
105/**
106 * Le fichier qu'un poste publie dans le dépôt partagé `claude-usage` (`machines/<nom>.json`) :
107 * ses lignes, ses sessions et les projets qu'elles citent. Les autres postes le lisent.
108 */
109export type Published = {
110  schema: number
111  machine: string
112  /** Dernière publication dont les données ont changé, en ms. */
113  updatedAt: number
114  tzOffset: number
115  rows: UsageRow[]
116  sessions: SessionRow[]
117  projects: Record<string, ProjectInfo>
118}
119
120export type Tab = 'discussion' | 'day' | 'overview' | 'trend' | 'models' | 'projects' | 'sessions' | 'rhythm'
121export type Period = 'all' | '90' | '30' | '7'
122/** Le filtre de poste : `all`, ou le nom d'un poste. */
123export type Machine = 'all' | (string & {})
124export type Metric = 'cost' | 'tokens'
125export type Grain = 'day' | 'week' | 'month'
126export type SessionSort = 'cost' | 'recent' | 'long'
127
128/** Listes du panneau dépliées au-delà de leur plafond (« ▸ tout voir (N) »). */
129export type Expanded = { models: boolean; projects: boolean; sessions: boolean; days: boolean }
130
131/** Les choix de l'affichage : onglet, période, filtres, mesure. */
132export type Ui = {
133  tab: Tab
134  period: Period
135  machine: Machine
136  metric: Metric
137  grain: Grain
138  /** Filtre de projet (clé ccusage, ou `@brouillons`) ; null pour tous. */
139  project: string | null
140  /** Filtre de modèle ; null pour tous. */
141  model: string | null
142  sessionSort: SessionSort
143  expanded: Expanded
144  /** Jour choisi dans l'onglet Journée (AAAA-MM-JJ) ; null pour celui du relevé. */
145  day: string | null
146}
147
148export type Meta = {
149  /** Étape du relevé en cours ; null au repos. */
150  loading: string | null
151  error: string | null
152  /** Début du relevé en cours, en ms. */
153  startedAt: number | null
154}
155
156export type Notice = { text: string; tone: 'ok' | 'error' | 'info' } | null
157
158/** Un appel API de la discussion (`ccusage claude session --id`) : heure (ms), modèle, entrée, sortie, cache écrit, cache lu. */
159export type DiscussionEntry = [at: number, model: string, input: number, output: number, cacheWrite: number, cacheRead: number]
160
161/** Les tokens d'un tour fini de la conversation principale, d'après le moteur (`turn.complete`). */
162export type TurnTokens = { at: number; tokens: number }
163
164/** La discussion ouverte : chiffres du moteur en direct, détail de ccusage à la demande. */
165export type Discussion = {
166  /** Identifiant de la session (le `sessionId` de ccusage). */
167  id: string | null
168  /** D'après le moteur (`$.session.usage()`), relu à chaque tour ; null avant la première lecture. */
169  live: {
170    /** Coût en dollars, comme `/cost`. */
171    cost: number | null
172    contextPercent: number | null
173    contextTokens: number | null
174    window: number | null
175    turns: number
176    model: string | null
177    startedAt: number | null
178    at: number
179  } | null
180  /** Le détail de ccusage (`claude session --id`), au dernier ↻ ; null avant. */
181  detail: { fetchedAt: number; cost: number; tokens: number; entries: DiscussionEntry[] } | null
182  /** Tours finis depuis le chargement du mod, pour ajouter aux tokens d'un relevé plus ancien qu'eux. */
183  turns: TurnTokens[]
184  loading: boolean
185  error: string | null
186}
187
188declare module 'claude-code' {
189  interface PluginState {
190    'stats-board': {
191      snapshot: Snapshot | null
192      ui: Ui
193      meta: Meta
194      notice: Notice
195      /** Bandeau au-dessus du prompt masqué (`x` ou `/ccstats hide`), le temps de la session. */
196      isBandHidden: boolean
197      /** Clé ccusage du dossier de la session (« Ce projet »). */
198      here: string | null
199      discussion: Discussion
200    }
201  }
202}
203