feat: nature des échanges — catégorisation et détail des commentaires de transactions
ci/woodpecker/push/woodpecker Pipeline was successful

- Nouveau commentParser.ts : ~80 règles regex multilingues, 11 catégories
- SubsquidAdapter : fetch du champ comment.remark depuis SubSquid
- Transaction et TransactionArc : champs comment et category
- StatsPanel : section Nature des échanges avec barres cliquables (détail inline)
- FlowMap : tooltip au survol des arcs avec répartition catégories + commentaires
- InfoPanel mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-04-21 21:29:59 +02:00
parent 6b7591db32
commit 8e396cd331
10 changed files with 562 additions and 27 deletions
+73 -2
View File
@@ -1,9 +1,11 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData';
interface StatsPanelProps {
stats: PeriodStats | null;
@@ -19,6 +21,7 @@ interface StatsPanelProps {
onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
transactions?: Transaction[];
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -65,8 +68,9 @@ function CityRow({ city, volume, count, countryCode, accent }: {
);
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [] }: StatsPanelProps) {
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -309,6 +313,73 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
</>
)}
{/* Nature des échanges */}
{!loading && stats && stats.categoryBreakdown.length > 0 && stats.commentedCount > 0 && (
<div className="space-y-2 border-t border-[#1e1f2a] pt-3">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nature des échanges</p>
<p className="text-[#2e2f3a] text-[10px] font-mono">{stats.commentedCount} commentés</p>
</div>
<div className="space-y-1">
{stats.categoryBreakdown
.filter((c) => c.category !== 'migration' && c.category !== 'ticket')
.slice(0, 7)
.map((c) => {
const pct = Math.round((c.count / stats.commentedCount) * 100);
const isOpen = openCategory === c.category;
const detail = transactions.filter((t) => t.category === c.category && t.comment);
return (
<div key={c.category}>
<button
onClick={() => setOpenCategory(isOpen ? null : c.category)}
className="w-full text-left group"
>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-[11px] transition-colors ${isOpen ? 'text-white' : 'text-[#9ca3af] group-hover:text-white'}`}>
{isOpen ? '▾' : '▸'} {CATEGORY_LABELS[c.category]}
</span>
<span className="text-[#4b5563] text-[10px] font-mono">{c.count} · {pct}%</span>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1">
<div
className="h-1 rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }}
/>
</div>
</button>
{isOpen && (
<div className="mt-1.5 mb-1 bg-[#0a0b0f] border border-[#1e1f2a] rounded-lg overflow-hidden">
{detail.length === 0 ? (
<p className="text-[#4b5563] text-[10px] px-3 py-2">Aucun commentaire disponible.</p>
) : (
<div className="max-h-48 overflow-y-auto divide-y divide-[#1e1f2a]">
{detail.slice(0, 30).map((t) => (
<div key={t.id} className="px-3 py-1.5 flex items-start justify-between gap-2">
<p className="text-[#9ca3af] text-[10px] italic leading-snug flex-1 min-w-0 truncate">
"{t.comment}"
</p>
<span className="text-[#d4a843] text-[10px] font-mono shrink-0">
{t.amount.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} Ğ1
</span>
</div>
))}
{detail.length > 30 && (
<p className="text-[#4b5563] text-[10px] px-3 py-1.5 text-center">
+{detail.length - 30} autres
</p>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center">