| 147 | /** Responsive CSS bar chart (no SVG stretching, so bars stay crisp and the |
| 148 | * summary markers render as true round dots regardless of bucket count). */ |
| 149 | export function TimelineChart(props: { buckets?: any[]; nodeBuckets?: any[]; undatedCount?: any }): React.ReactElement { |
| 150 | // Buckets cover only messages with real timestamps; the server reports |
| 151 | // messages without one separately so they surface as an honest note |
| 152 | // instead of a fake single bar. |
| 153 | const buckets = (props.buckets || []).filter(function (b) { return b && b.bucket != null; }); |
| 154 | const nodeBuckets = props.nodeBuckets || []; |
| 155 | const undatedCount = Number(props.undatedCount) || 0; |
| 156 | if (!buckets.length) { |
| 157 | return ( |
| 158 | <EmptyState className="hermes-lcm-empty"> |
| 159 | {undatedCount > 0 |
| 160 | ? `No dated messages yet — ${fmtInt(undatedCount)} stored messages have no timestamp` |
| 161 | : "No timeline data"} |
| 162 | </EmptyState> |
| 163 | ); |
| 164 | } |
| 165 | const maxCount = buckets.reduce((acc, b) => Math.max(acc, Number(b.count) || 0), 0) || 1; |
| 166 | const nodeByBucket: Record<string, number> = {}; |
| 167 | nodeBuckets.forEach(function (nb) { nodeByBucket[nb.bucket] = Number(nb.count) || 0; }); |
| 168 | |
| 169 | const cols = buckets.map(function (b, i) { |
| 170 | const count = Number(b.count) || 0; |
| 171 | const pct = count > 0 ? Math.max(3, Math.round((count / maxCount) * 100)) : 0; |
| 172 | const nodes = nodeByBucket[b.bucket] || 0; |
| 173 | const tip = `${b.bucket}: ${fmtInt(count)} messages` |
| 174 | + (nodes ? ` · ${fmtInt(nodes)} summaries` : ""); |
| 175 | return ( |
| 176 | <div key={b.bucket + i} className="hermes-lcm-tl-col" title={tip}> |
| 177 | <div className={"hermes-lcm-tl-dot" + (nodes ? " hermes-lcm-tl-dot-on" : "")} /> |
| 178 | <div className="hermes-lcm-tl-bar" style={{ height: pct + "%" }} /> |
| 179 | </div> |
| 180 | ); |
| 181 | }); |
| 182 | |
| 183 | return ( |
| 184 | <div className="hermes-lcm-tl"> |
| 185 | <div className="hermes-lcm-tl-bars">{cols}</div> |
| 186 | <div className="hermes-lcm-svg-axis"> |
| 187 | <span>{short(buckets[0].bucket, 16)}</span> |
| 188 | <span>{short(buckets[buckets.length - 1].bucket, 16)}</span> |
| 189 | </div> |
| 190 | {undatedCount > 0 ? ( |
| 191 | <div className="hermes-lcm-dim hermes-lcm-tl-undated"> |
| 192 | {`${fmtInt(undatedCount)} undated messages not shown`} |
| 193 | </div> |
| 194 | ) : null} |
| 195 | </div> |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | /** Per-group compression (kept vs saved), rendered as a small inline SVG. */ |
| 200 | export function CompressionBars(props: { groups?: any[]; onPick?: (g: any) => void }): React.ReactElement { |