dsh-token-pulse 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -430,7 +430,7 @@ window.__ModuleLoader__.load({
430
430
  }));
431
431
  return out;
432
432
  }
433
- function formatTokensShort(n) {
433
+ function formatTokensShort$1(n) {
434
434
  if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
435
435
  if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
436
436
  if (n >= 1e3) return (n / 1e3).toFixed(2) + "k";
@@ -438,7 +438,7 @@ window.__ModuleLoader__.load({
438
438
  }
439
439
  function tooltipTotal(day) {
440
440
  if (day.totalTokens === 0) return `${day.dayKey}: No usage`;
441
- return `${day.dayKey}: ${formatTokensShort(day.totalTokens)} tokens`;
441
+ return `${day.dayKey}: ${formatTokensShort$1(day.totalTokens)} tokens`;
442
442
  }
443
443
  function HeatCell({ day, level, size = 14 }) {
444
444
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -481,7 +481,7 @@ window.__ModuleLoader__.load({
481
481
  },
482
482
  children: hourly.map((v, h) => {
483
483
  const lv = hourLevels[h] ?? 0;
484
- const hh = v === 0 ? `${String(h).padStart(2, "0")}:00 — No usage` : `${String(h).padStart(2, "0")}:00 — ${formatTokensShort(v)} tokens`;
484
+ const hh = v === 0 ? `${String(h).padStart(2, "0")}:00 — No usage` : `${String(h).padStart(2, "0")}:00 — ${formatTokensShort$1(v)} tokens`;
485
485
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
486
486
  title: hh,
487
487
  "aria-label": hh,
@@ -904,7 +904,7 @@ window.__ModuleLoader__.load({
904
904
  textOverflow: "ellipsis",
905
905
  maxWidth: "100%"
906
906
  },
907
- children: d.totalTokens > 0 ? formatTokensShort(d.totalTokens) : ""
907
+ children: d.totalTokens > 0 ? formatTokensShort$1(d.totalTokens) : ""
908
908
  })]
909
909
  }, d.dayKey);
910
910
  })
@@ -1024,7 +1024,7 @@ window.__ModuleLoader__.load({
1024
1024
  width: 80,
1025
1025
  textAlign: "right"
1026
1026
  },
1027
- children: d.totalTokens === 0 ? "No usage" : `${formatTokensShort(d.totalTokens)} tokens`
1027
+ children: d.totalTokens === 0 ? "No usage" : `${formatTokensShort$1(d.totalTokens)} tokens`
1028
1028
  })
1029
1029
  ]
1030
1030
  }, d.dayKey);
@@ -1260,6 +1260,664 @@ window.__ModuleLoader__.load({
1260
1260
  });
1261
1261
  }
1262
1262
  //#endregion
1263
+ //#region src/client/trend.ts
1264
+ /**
1265
+ * Pure logic for the bottom-of-page trend line chart: window slicing,
1266
+ * top-model selection, per-model series building and monotone cubic smoothing.
1267
+ * Kept free of React/DOM so it is unit-testable and reusable.
1268
+ */
1269
+ /** N consecutive day keys ending at `endKey` (inclusive). */
1270
+ function recentDayKeys(endKey, n) {
1271
+ const out = [];
1272
+ for (let i = n - 1; i >= 0; i--) out.push(addDays(endKey, -i));
1273
+ return out;
1274
+ }
1275
+ /** Sum each model's tokens over `dayKeys`. */
1276
+ function modelTotals(days, dayKeys) {
1277
+ const inWindow = new Set(dayKeys);
1278
+ const totals = /* @__PURE__ */ new Map();
1279
+ for (const d of days) {
1280
+ if (!inWindow.has(d.dayKey)) continue;
1281
+ for (const [model, tokens] of d.byModel) totals.set(model, (totals.get(model) ?? 0) + tokens);
1282
+ }
1283
+ return totals;
1284
+ }
1285
+ const byTotalDesc = (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1);
1286
+ /** Sum each model's tokens over `dayKeys`, return up to `k` names by total desc. */
1287
+ function pickTopModels(days, dayKeys, k = 5) {
1288
+ return [...modelTotals(days, dayKeys).entries()].sort(byTotalDesc).slice(0, k).map(([model]) => model);
1289
+ }
1290
+ /**
1291
+ * Legend selection for the trend chart: the range's top-k models PLUS the
1292
+ * week's top-`weekTopN` models guaranteed (a model that took over this week
1293
+ * must stay visible in a 30/90-day view). If the union exceeds `k`, the
1294
+ * weakest range-ranked models give up their slots. Result is ordered by range
1295
+ * usage desc, deduplicated, at most `k` names.
1296
+ */
1297
+ function pickTrendModels(days, dayKeys, weekKeys, k = 10, weekTopN = 3) {
1298
+ const rangeTotals = modelTotals(days, dayKeys);
1299
+ const rangeRanked = [...rangeTotals.entries()].sort(byTotalDesc).slice(0, k).map(([m]) => m);
1300
+ const weekTop = pickTopModels(days, weekKeys, weekTopN);
1301
+ const selected = new Set(weekTop);
1302
+ for (const m of rangeRanked) {
1303
+ if (selected.size >= k) break;
1304
+ selected.add(m);
1305
+ }
1306
+ return [...selected].sort((a, b) => (rangeTotals.get(b) ?? 0) - (rangeTotals.get(a) ?? 0) || (a < b ? -1 : 1));
1307
+ }
1308
+ /** One series per model: its daily tokens aligned to `dayKeys`, 0 where absent. */
1309
+ function buildSeries(days, dayKeys, models) {
1310
+ const index = new Map(days.map((d) => [d.dayKey, d]));
1311
+ return models.map((model) => ({
1312
+ model,
1313
+ points: dayKeys.map((k) => index.get(k)?.byModel.get(model) ?? 0)
1314
+ }));
1315
+ }
1316
+ /**
1317
+ * Monotone cubic (Fritsch–Carlson) Hermite interpolation of 2-D points as
1318
+ * cubic bezier segments. Used instead of plain Catmull-Rom because a smooth
1319
+ * trend line must NOT overshoot: a zero day between two peaks stays at zero
1320
+ * instead of dipping below the axis and inventing a fake trough.
1321
+ */
1322
+ function monotoneSegments(pts) {
1323
+ const n = pts.length;
1324
+ if (n < 2) return [];
1325
+ const h = [];
1326
+ const delta = [];
1327
+ for (let i = 0; i < n - 1; i++) {
1328
+ const dx = pts[i + 1][0] - pts[i][0];
1329
+ h.push(dx);
1330
+ delta.push(dx === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx);
1331
+ }
1332
+ const m = new Array(n);
1333
+ m[0] = delta[0];
1334
+ m[n - 1] = delta[n - 2];
1335
+ for (let i = 1; i < n - 1; i++) m[i] = delta[i - 1] * delta[i] <= 0 ? 0 : (delta[i - 1] + delta[i]) / 2;
1336
+ for (let i = 0; i < n - 1; i++) {
1337
+ if (delta[i] === 0) {
1338
+ m[i] = 0;
1339
+ m[i + 1] = 0;
1340
+ continue;
1341
+ }
1342
+ const a = m[i] / delta[i];
1343
+ const b = m[i + 1] / delta[i];
1344
+ const s = a * a + b * b;
1345
+ if (s > 9) {
1346
+ const t = 3 / Math.sqrt(s);
1347
+ m[i] = t * a * delta[i];
1348
+ m[i + 1] = t * b * delta[i];
1349
+ }
1350
+ }
1351
+ const segs = [];
1352
+ for (let i = 0; i < n - 1; i++) {
1353
+ const [x0, y0] = pts[i];
1354
+ const [x1, y1] = pts[i + 1];
1355
+ const dx = (x1 - x0) / 3;
1356
+ segs.push({
1357
+ p0: [x0, y0],
1358
+ c1: [x0 + dx, y0 + m[i] * (x1 - x0) / 3],
1359
+ c2: [x1 - dx, y1 - m[i + 1] * (x1 - x0) / 3],
1360
+ p1: [x1, y1]
1361
+ });
1362
+ }
1363
+ return segs;
1364
+ }
1365
+ const fmtNum = (v) => Math.round(v * 100) / 100;
1366
+ /** SVG path `d` for a smoothed polyline through `points` ([x,y] pairs). */
1367
+ function seriesPath(points) {
1368
+ if (points.length === 0) return "";
1369
+ const head = `M ${fmtNum(points[0][0])} ${fmtNum(points[0][1])}`;
1370
+ if (points.length === 1) return head;
1371
+ return head + monotoneSegments(points).map((s) => ` C ${fmtNum(s.c1[0])} ${fmtNum(s.c1[1])}, ${fmtNum(s.c2[0])} ${fmtNum(s.c2[1])}, ${fmtNum(s.p1[0])} ${fmtNum(s.p1[1])}`).join("");
1372
+ }
1373
+ /** Compact token count for axis labels (same semantics as the heatmap). */
1374
+ function formatTokensShort(n) {
1375
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
1376
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
1377
+ if (n >= 1e3) return (n / 1e3).toFixed(2) + "k";
1378
+ return n.toLocaleString();
1379
+ }
1380
+ //#endregion
1381
+ //#region src/client/TrendChart.tsx
1382
+ /** Distinct, colorblind-tolerant series colors (first matches the heatmap green). */
1383
+ const COLORS = [
1384
+ "#40c463",
1385
+ "#4c8dff",
1386
+ "#f6a609",
1387
+ "#c956e0",
1388
+ "#ff7a59",
1389
+ "#26c6da",
1390
+ "#ff5c8a",
1391
+ "#9ccc65",
1392
+ "#7e57c2",
1393
+ "#ffd54f"
1394
+ ];
1395
+ /** Round a max up to a friendly axis ceiling (steps of half a decade). */
1396
+ function niceCeil(v) {
1397
+ if (v <= 0) return 1;
1398
+ const unit = Math.pow(10, Math.floor(Math.log10(v))) / 2;
1399
+ return Math.ceil(v / unit) * unit;
1400
+ }
1401
+ const W = 720;
1402
+ const H = 280;
1403
+ const PAD = {
1404
+ top: 10,
1405
+ right: 28,
1406
+ bottom: 34,
1407
+ left: 76
1408
+ };
1409
+ /** chart container horizontal padding (must match the div below) */
1410
+ const CX_PAD = 8;
1411
+ /**
1412
+ * Trend line chart at the bottom of the settings section: one smoothed line
1413
+ * per model (top 10 of the selected range), legend chips toggle visibility,
1414
+ * range switch between the last 7 / 30 / 90 days. Hovering a date column shows a
1415
+ * compact translucent tooltip BESIDE the cursor (never covering the hovered
1416
+ * point): date, day total, per-model totals with color dots.
1417
+ * Pure SVG + absolutely-positioned HTML tooltip, no dependencies.
1418
+ */
1419
+ function TrendChart({ t, days, isEn = false }) {
1420
+ const [rangeDays, setRangeDays] = react.useState(7);
1421
+ const [hidden, setHidden] = react.useState(/* @__PURE__ */ new Set());
1422
+ /** hovered column index + pointer position in wrapper px (for side placement) */
1423
+ const [hover, setHover] = react.useState(null);
1424
+ const endKey = toDayKey(Date.now());
1425
+ const dayKeys = react.useMemo(() => recentDayKeys(endKey, rangeDays), [endKey, rangeDays]);
1426
+ const weekKeys = react.useMemo(() => recentDayKeys(endKey, 7), [endKey]);
1427
+ const topModels = react.useMemo(() => pickTrendModels(days, dayKeys, weekKeys, 10, 3), [
1428
+ days,
1429
+ dayKeys,
1430
+ weekKeys
1431
+ ]);
1432
+ const series = react.useMemo(() => buildSeries(days, dayKeys, topModels), [
1433
+ days,
1434
+ dayKeys,
1435
+ topModels
1436
+ ]);
1437
+ const totalsByModel = react.useMemo(() => {
1438
+ const m = /* @__PURE__ */ new Map();
1439
+ for (const s of series) m.set(s.model, s.points.reduce((a, b) => a + b, 0));
1440
+ return m;
1441
+ }, [series]);
1442
+ const wrapRef = react.useRef(null);
1443
+ const [wrap, setWrap] = react.useState({
1444
+ w: 0,
1445
+ h: 0
1446
+ });
1447
+ react.useEffect(() => {
1448
+ const el = wrapRef.current;
1449
+ if (!el) return;
1450
+ const measure = () => setWrap({
1451
+ w: el.clientWidth,
1452
+ h: el.clientHeight
1453
+ });
1454
+ measure();
1455
+ const ro = new ResizeObserver(measure);
1456
+ ro.observe(el);
1457
+ return () => ro.disconnect();
1458
+ }, []);
1459
+ const toggle = (model) => {
1460
+ setHidden((prev) => {
1461
+ const next = new Set(prev);
1462
+ if (next.has(model)) next.delete(model);
1463
+ else next.add(model);
1464
+ return next;
1465
+ });
1466
+ };
1467
+ const colorOf = (model) => COLORS[Math.max(0, topModels.indexOf(model)) % COLORS.length];
1468
+ const visible = series.filter((s) => !hidden.has(s.model));
1469
+ const plotW = W - PAD.left - PAD.right;
1470
+ const plotH = H - PAD.top - PAD.bottom;
1471
+ const yMax = niceCeil(Math.max(1, ...visible.map((s) => Math.max(0, ...s.points))));
1472
+ const step = dayKeys.length > 1 ? plotW / (dayKeys.length - 1) : plotW;
1473
+ const xOf = (i) => PAD.left + (dayKeys.length > 1 ? i * step : plotW / 2);
1474
+ const yOf = (v) => PAD.top + (1 - v / yMax) * plotH;
1475
+ const fmtDay = (k) => `${Number(k.slice(5, 7))}/${Number(k.slice(8, 10))}`;
1476
+ const fmtDayLong = (k) => isEn ? `${Number(k.slice(5, 7))}/${Number(k.slice(8, 10))}` : `${Number(k.slice(5, 7))}月${Number(k.slice(8, 10))}日`;
1477
+ const tickEvery = rangeDays === 7 ? 1 : rangeDays === 30 ? 5 : 10;
1478
+ const tickIdx = dayKeys.map((_, i) => i).filter((i) => i % tickEvery === 0 || i === dayKeys.length - 1);
1479
+ const onMove = (e) => {
1480
+ const wrapEl = wrapRef.current;
1481
+ if (!wrapEl) return;
1482
+ const wrapRect = wrapEl.getBoundingClientRect();
1483
+ const svgRect = e.currentTarget.getBoundingClientRect();
1484
+ const scale = svgRect.width / W || 1;
1485
+ const xvb = (e.clientX - svgRect.left) / scale;
1486
+ const i = Math.round((xvb - PAD.left) / step);
1487
+ setHover({
1488
+ idx: Math.max(0, Math.min(dayKeys.length - 1, i)),
1489
+ px: e.clientX - wrapRect.left,
1490
+ py: e.clientY - wrapRect.top
1491
+ });
1492
+ };
1493
+ const hoverRows = hover == null ? [] : visible.map((s) => ({
1494
+ model: s.model,
1495
+ color: colorOf(s.model),
1496
+ value: s.points[hover.idx]
1497
+ })).filter((r) => r.value > 0).sort((a, b) => b.value - a.value);
1498
+ const hoverTotal = hoverRows.reduce((a, r) => a + r.value, 0);
1499
+ const tipW = 200;
1500
+ const tipH = 56 + hoverRows.length * 19;
1501
+ const gap = 14;
1502
+ const svgW = Math.max(0, wrap.w - 16);
1503
+ const colPx = hover == null ? 0 : CX_PAD + xOf(hover.idx) / W * svgW;
1504
+ let tipLeft = hover == null ? 0 : colPx + gap;
1505
+ if (hover != null && tipLeft + tipW > wrap.w - 4) tipLeft = colPx - gap - tipW;
1506
+ tipLeft = Math.max(4, tipLeft);
1507
+ const tipTop = hover == null ? 0 : Math.max(4, Math.min(hover.py - tipH / 2, Math.max(4, wrap.h - tipH - 4)));
1508
+ const segStyle = {
1509
+ padding: "2px 8px",
1510
+ fontSize: 12,
1511
+ border: "none",
1512
+ cursor: "pointer",
1513
+ borderRadius: 5,
1514
+ background: "var(--dsw-alias-bg-layer-3)",
1515
+ color: "var(--dsw-alias-label-secondary)"
1516
+ };
1517
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1518
+ style: {
1519
+ border: "1px solid var(--dsw-alias-border-l2)",
1520
+ background: "var(--dsw-alias-bg-layer-3)",
1521
+ borderRadius: 12,
1522
+ overflow: "hidden"
1523
+ },
1524
+ children: [
1525
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1526
+ style: {
1527
+ padding: "12px 16px 8px",
1528
+ borderBottom: "1px solid var(--dsw-alias-border-l2)",
1529
+ display: "flex",
1530
+ alignItems: "center",
1531
+ justifyContent: "space-between",
1532
+ gap: 8
1533
+ },
1534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1535
+ style: {
1536
+ display: "flex",
1537
+ alignItems: "center",
1538
+ gap: 8
1539
+ },
1540
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1541
+ style: {
1542
+ fontSize: 13,
1543
+ fontWeight: 600,
1544
+ color: "var(--dsw-alias-label-primary)"
1545
+ },
1546
+ children: t("trend.title")
1547
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1548
+ style: {
1549
+ fontSize: 12,
1550
+ color: "var(--dsw-alias-label-tertiary)"
1551
+ },
1552
+ children: t("trend.hint")
1553
+ })]
1554
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1555
+ style: {
1556
+ display: "inline-flex",
1557
+ border: "1px solid var(--dsw-alias-border-l2)",
1558
+ borderRadius: 6,
1559
+ overflow: "hidden"
1560
+ },
1561
+ children: [
1562
+ 7,
1563
+ 30,
1564
+ 90
1565
+ ].map((n) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1566
+ onClick: () => {
1567
+ setRangeDays(n);
1568
+ setHover(null);
1569
+ },
1570
+ style: {
1571
+ ...segStyle,
1572
+ background: rangeDays === n ? "var(--dsw-alias-label-primary)" : "transparent",
1573
+ color: rangeDays === n ? "var(--dsw-alias-bg-layer-3)" : "var(--dsw-alias-label-secondary)"
1574
+ },
1575
+ children: n === 7 ? t("trend.range7") : n === 30 ? t("trend.range30") : t("trend.range90")
1576
+ }, n))
1577
+ })]
1578
+ }),
1579
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1580
+ style: { padding: "10px 16px 4px" },
1581
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1582
+ style: {
1583
+ display: "grid",
1584
+ gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
1585
+ gap: 6
1586
+ },
1587
+ children: series.map((s) => {
1588
+ const off = hidden.has(s.model);
1589
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1590
+ onClick: () => toggle(s.model),
1591
+ title: s.model,
1592
+ style: {
1593
+ display: "inline-flex",
1594
+ alignItems: "center",
1595
+ gap: 4,
1596
+ padding: "1px 6px",
1597
+ borderRadius: 999,
1598
+ border: "1px solid var(--dsw-alias-border-l2)",
1599
+ background: "transparent",
1600
+ cursor: "pointer",
1601
+ opacity: off ? .35 : 1,
1602
+ fontSize: 11,
1603
+ color: "var(--dsw-alias-label-primary)",
1604
+ minWidth: 0
1605
+ },
1606
+ children: [
1607
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
1608
+ width: 6,
1609
+ height: 6,
1610
+ borderRadius: 3,
1611
+ background: colorOf(s.model),
1612
+ flexShrink: 0
1613
+ } }),
1614
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1615
+ style: {
1616
+ flex: 1,
1617
+ minWidth: 0,
1618
+ overflow: "hidden",
1619
+ textOverflow: "ellipsis",
1620
+ whiteSpace: "nowrap",
1621
+ textAlign: "left",
1622
+ textDecoration: off ? "line-through" : "none"
1623
+ },
1624
+ children: s.model
1625
+ }),
1626
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1627
+ style: {
1628
+ fontSize: 10,
1629
+ color: "var(--dsw-alias-label-tertiary)",
1630
+ flexShrink: 0
1631
+ },
1632
+ children: formatTokensShort(totalsByModel.get(s.model) ?? 0)
1633
+ })
1634
+ ]
1635
+ }, s.model);
1636
+ })
1637
+ }), series.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1638
+ style: {
1639
+ fontSize: 10,
1640
+ color: "var(--dsw-alias-label-tertiary)",
1641
+ marginTop: 4
1642
+ },
1643
+ children: t("trend.legend.hint")
1644
+ })]
1645
+ }),
1646
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1647
+ ref: wrapRef,
1648
+ style: {
1649
+ position: "relative",
1650
+ padding: `4px ${CX_PAD}px 12px`
1651
+ },
1652
+ children: [topModels.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1653
+ style: {
1654
+ padding: 24,
1655
+ textAlign: "center",
1656
+ fontSize: 14,
1657
+ color: "var(--dsw-alias-label-tertiary)"
1658
+ },
1659
+ children: t("trend.empty")
1660
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1661
+ viewBox: `0 0 ${W} ${H}`,
1662
+ width: "100%",
1663
+ style: { display: "block" },
1664
+ role: "img",
1665
+ "aria-label": t("trend.title"),
1666
+ onMouseMove: onMove,
1667
+ onMouseLeave: () => setHover(null),
1668
+ children: [
1669
+ [
1670
+ 0,
1671
+ .25,
1672
+ .5,
1673
+ .75,
1674
+ 1
1675
+ ].map((f) => {
1676
+ const y = PAD.top + (1 - f) * plotH;
1677
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1678
+ x1: PAD.left,
1679
+ y1: y,
1680
+ x2: W - PAD.right,
1681
+ y2: y,
1682
+ stroke: "var(--dsw-alias-border-l2)",
1683
+ strokeWidth: 1,
1684
+ strokeDasharray: f === 0 ? void 0 : "3 3"
1685
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1686
+ x: PAD.left - 8,
1687
+ y: y + 5,
1688
+ textAnchor: "end",
1689
+ fontSize: 14,
1690
+ fill: "var(--dsw-alias-label-tertiary)",
1691
+ children: formatTokensShort(yMax * f)
1692
+ })] }, f);
1693
+ }),
1694
+ tickIdx.map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1695
+ x: xOf(i),
1696
+ y: 270,
1697
+ textAnchor: "middle",
1698
+ fontSize: 14,
1699
+ fill: "var(--dsw-alias-label-tertiary)",
1700
+ children: fmtDay(dayKeys[i])
1701
+ }, i)),
1702
+ hover != null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1703
+ x1: xOf(hover.idx),
1704
+ y1: PAD.top,
1705
+ x2: xOf(hover.idx),
1706
+ y2: PAD.top + plotH,
1707
+ stroke: "var(--dsw-alias-label-tertiary)",
1708
+ strokeWidth: 1,
1709
+ strokeDasharray: "4 3",
1710
+ opacity: .6
1711
+ }) : null,
1712
+ visible.map((s) => {
1713
+ const color = colorOf(s.model);
1714
+ const pts = s.points.map((v, i) => [xOf(i), yOf(v)]);
1715
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", {
1716
+ opacity: .8,
1717
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1718
+ d: seriesPath(pts),
1719
+ fill: "none",
1720
+ stroke: color,
1721
+ strokeWidth: 2,
1722
+ strokeLinecap: "round",
1723
+ strokeLinejoin: "round"
1724
+ }), pts.map(([x, y], i) => s.points[i] > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1725
+ cx: x,
1726
+ cy: y,
1727
+ r: hover?.idx === i ? 4 : 2,
1728
+ fill: color
1729
+ }, i) : null)]
1730
+ }, s.model);
1731
+ }),
1732
+ visible.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1733
+ x: W / 2,
1734
+ y: PAD.top + plotH / 2,
1735
+ textAnchor: "middle",
1736
+ fontSize: 13,
1737
+ fill: "var(--dsw-alias-label-tertiary)",
1738
+ children: t("trend.legend.hint")
1739
+ }) : null
1740
+ ]
1741
+ }), hover != null && topModels.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1742
+ style: {
1743
+ position: "absolute",
1744
+ left: tipLeft,
1745
+ top: tipTop,
1746
+ width: tipW,
1747
+ opacity: .7,
1748
+ pointerEvents: "none",
1749
+ background: "var(--dsw-alias-bg-layer-3)",
1750
+ border: "1px solid var(--dsw-alias-border-l2)",
1751
+ borderRadius: 8,
1752
+ boxShadow: "0 6px 18px rgba(0,0,0,0.25)",
1753
+ padding: "6px 10px",
1754
+ fontSize: 11,
1755
+ lineHeight: 1.5,
1756
+ color: "var(--dsw-alias-label-primary)",
1757
+ zIndex: 5
1758
+ },
1759
+ children: [
1760
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1761
+ style: {
1762
+ fontWeight: 600,
1763
+ fontSize: 12,
1764
+ marginBottom: 4
1765
+ },
1766
+ children: fmtDayLong(dayKeys[hover.idx])
1767
+ }),
1768
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1769
+ style: {
1770
+ display: "flex",
1771
+ justifyContent: "space-between",
1772
+ gap: 10,
1773
+ fontWeight: 600
1774
+ },
1775
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: isEn ? "Total" : "消耗总量" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [formatTokensShort(hoverTotal), " tokens"] })]
1776
+ }),
1777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
1778
+ borderTop: "1px solid var(--dsw-alias-border-l2)",
1779
+ margin: "4px 0"
1780
+ } }),
1781
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1782
+ style: {
1783
+ display: "flex",
1784
+ flexDirection: "column",
1785
+ gap: 3
1786
+ },
1787
+ children: hoverRows.map((r) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1788
+ style: {
1789
+ display: "flex",
1790
+ alignItems: "center",
1791
+ gap: 6
1792
+ },
1793
+ children: [
1794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
1795
+ width: 7,
1796
+ height: 7,
1797
+ borderRadius: 4,
1798
+ background: r.color,
1799
+ flexShrink: 0
1800
+ } }),
1801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1802
+ style: {
1803
+ flex: 1,
1804
+ minWidth: 0,
1805
+ overflow: "hidden",
1806
+ textOverflow: "ellipsis",
1807
+ whiteSpace: "nowrap"
1808
+ },
1809
+ title: r.model,
1810
+ children: r.model
1811
+ }),
1812
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1813
+ style: {
1814
+ fontWeight: 500,
1815
+ whiteSpace: "nowrap"
1816
+ },
1817
+ children: [formatTokensShort(r.value), " tokens"]
1818
+ })
1819
+ ]
1820
+ }, r.model))
1821
+ })
1822
+ ]
1823
+ }) : null]
1824
+ })
1825
+ ]
1826
+ });
1827
+ }
1828
+ //#endregion
1829
+ //#region src/client/locales.ts
1830
+ const zh = {
1831
+ "nav": "Token Pulse",
1832
+ "heatmap.title": "Token Pulse",
1833
+ "heatmap.subtitle": "基于本地会话日志统计 Token 用量,北京时间自然日分桶,零网络零计费。首次同步后持久化,删除会话不丢历史。",
1834
+ "heatmap.empty": "暂无用量数据,开始一次对话后这里会出现热图。",
1835
+ "heatmap.legend.less": "少",
1836
+ "heatmap.legend.more": "多",
1837
+ "heatmap.tooltip.tokens": "{count} tokens",
1838
+ "heatmap.tooltip.none": "无用量",
1839
+ "heatmap.refresh": "刷新",
1840
+ "heatmap.refreshing": "刷新中…",
1841
+ "heatmap.lastRefresh": "上次刷新:{time}",
1842
+ "heatmap.autoRefresh": "自动刷新",
1843
+ "heatmap.autoRefresh.off": "关闭",
1844
+ "heatmap.autoRefresh.hint": "设置自动刷新间隔(分钟)",
1845
+ "heatmap.reset": "重置历史",
1846
+ "heatmap.reset.confirm": "确定重置所有历史数据?此操作不可撤销。",
1847
+ "heatmap.reset.done": "已重置",
1848
+ "view.week": "周",
1849
+ "view.month": "月",
1850
+ "view.quarter": "季度",
1851
+ "view.year": "年",
1852
+ "view.model": "模型",
1853
+ "view.provider": "供应商",
1854
+ "stats.today": "今日",
1855
+ "stats.week": "本周",
1856
+ "stats.month": "本月",
1857
+ "stats.all": "累计",
1858
+ "stats.count": "{count} 次",
1859
+ "model.top5": "Top 5 模型",
1860
+ "model.top5.hint": "按 Token 用量排序",
1861
+ "model.winner": "当日最常用模型",
1862
+ "model.none": "暂无模型数据",
1863
+ "model.tokens": "{count} tokens",
1864
+ "provider.top5": "Top 5 供应商",
1865
+ "provider.winner": "当日最常用供应商",
1866
+ "trend.title": "用量趋势",
1867
+ "trend.hint": "按模型的每日 Token 用量",
1868
+ "trend.range7": "近一周",
1869
+ "trend.range30": "近一月",
1870
+ "trend.range90": "近90天",
1871
+ "trend.empty": "所选范围内暂无数据",
1872
+ "trend.legend.hint": "点击图例可显示/隐藏",
1873
+ "common.tokens": "tokens"
1874
+ };
1875
+ const en = {
1876
+ "nav": "Token Pulse",
1877
+ "heatmap.title": "Token Pulse",
1878
+ "heatmap.subtitle": "Local token usage from session logs, bucketed by Asia/Shanghai, no network or billing. Synced once then persisted, surviving session deletion.",
1879
+ "heatmap.empty": "No usage yet. Start a conversation and the heatmap will appear here.",
1880
+ "heatmap.legend.less": "Less",
1881
+ "heatmap.legend.more": "More",
1882
+ "heatmap.tooltip.tokens": "{count} tokens",
1883
+ "heatmap.tooltip.none": "No usage",
1884
+ "heatmap.refresh": "Refresh",
1885
+ "heatmap.refreshing": "Refreshing…",
1886
+ "heatmap.lastRefresh": "Last refresh: {time}",
1887
+ "heatmap.autoRefresh": "Auto refresh",
1888
+ "heatmap.autoRefresh.off": "Off",
1889
+ "heatmap.autoRefresh.hint": "Auto refresh interval (minutes)",
1890
+ "heatmap.reset": "Reset history",
1891
+ "heatmap.reset.confirm": "Reset all historical data? This cannot be undone.",
1892
+ "heatmap.reset.done": "Reset done",
1893
+ "view.week": "Week",
1894
+ "view.month": "Month",
1895
+ "view.quarter": "Quarter",
1896
+ "view.year": "Year",
1897
+ "view.model": "Model",
1898
+ "view.provider": "Provider",
1899
+ "stats.today": "Today",
1900
+ "stats.week": "This week",
1901
+ "stats.month": "This month",
1902
+ "stats.all": "All time",
1903
+ "stats.count": "{count} turns",
1904
+ "model.top5": "Top 5 Models",
1905
+ "model.top5.hint": "By token usage",
1906
+ "model.winner": "Daily winner (model)",
1907
+ "model.none": "No model data",
1908
+ "model.tokens": "{count} tokens",
1909
+ "provider.top5": "Top 5 Providers",
1910
+ "provider.winner": "Daily winner (provider)",
1911
+ "trend.title": "Usage Trend",
1912
+ "trend.hint": "Daily token usage per model",
1913
+ "trend.range7": "7 days",
1914
+ "trend.range30": "30 days",
1915
+ "trend.range90": "90 days",
1916
+ "trend.empty": "No data in this range",
1917
+ "trend.legend.hint": "Click legend to toggle",
1918
+ "common.tokens": "tokens"
1919
+ };
1920
+ //#endregion
1263
1921
  //#region src/client/hooks.ts
1264
1922
  const LS_INTERVAL_KEY = "dsh-token-pulse:autoRefreshMinutes";
1265
1923
  function getAutoRefreshMinutes() {
@@ -1543,6 +2201,10 @@ window.__ModuleLoader__.load({
1543
2201
  } catch {}
1544
2202
  };
1545
2203
  const days = injectedDays ?? computedDays;
2204
+ const allDays = react.useMemo(() => {
2205
+ if (aggregated?.byDay) return [...aggregated.byDay.values()];
2206
+ return days ?? [];
2207
+ }, [aggregated, days]);
1546
2208
  const totals = injectedTotals ?? aggregated?.totals;
1547
2209
  const viewTopModelsRaw = computedTopModels.length ? computedTopModels : aggregated?.topModels ?? [];
1548
2210
  const viewTopProvidersRaw = computedTopProviders.length ? computedTopProviders : aggregated?.topProviders ?? [];
@@ -1662,7 +2324,7 @@ window.__ModuleLoader__.load({
1662
2324
  color: "var(--dsw-alias-label-tertiary)",
1663
2325
  verticalAlign: "middle"
1664
2326
  },
1665
- children: ["v", "0.3.1"]
2327
+ children: ["v", "0.4.0"]
1666
2328
  })]
1667
2329
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1668
2330
  style: {
@@ -2117,89 +2779,16 @@ window.__ModuleLoader__.load({
2117
2779
  fontSize: 13
2118
2780
  },
2119
2781
  children: t("heatmap.empty")
2782
+ }) : null,
2783
+ allDays.some((d) => d.totalTokens > 0) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrendChart, {
2784
+ t: lang === "en" ? (k, p) => en[k] ?? t(k, p) : t,
2785
+ days: allDays,
2786
+ isEn: lang === "en"
2120
2787
  }) : null
2121
2788
  ]
2122
2789
  });
2123
2790
  }
2124
2791
  //#endregion
2125
- //#region src/client/locales.ts
2126
- const zh = {
2127
- "nav": "Token Pulse",
2128
- "heatmap.title": "Token Pulse",
2129
- "heatmap.subtitle": "基于本地会话日志统计 Token 用量,北京时间自然日分桶,零网络零计费。首次同步后持久化,删除会话不丢历史。",
2130
- "heatmap.empty": "暂无用量数据,开始一次对话后这里会出现热图。",
2131
- "heatmap.legend.less": "少",
2132
- "heatmap.legend.more": "多",
2133
- "heatmap.tooltip.tokens": "{count} tokens",
2134
- "heatmap.tooltip.none": "无用量",
2135
- "heatmap.refresh": "刷新",
2136
- "heatmap.refreshing": "刷新中…",
2137
- "heatmap.lastRefresh": "上次刷新:{time}",
2138
- "heatmap.autoRefresh": "自动刷新",
2139
- "heatmap.autoRefresh.off": "关闭",
2140
- "heatmap.autoRefresh.hint": "设置自动刷新间隔(分钟)",
2141
- "heatmap.reset": "重置历史",
2142
- "heatmap.reset.confirm": "确定重置所有历史数据?此操作不可撤销。",
2143
- "heatmap.reset.done": "已重置",
2144
- "view.week": "周",
2145
- "view.month": "月",
2146
- "view.quarter": "季度",
2147
- "view.year": "年",
2148
- "view.model": "模型",
2149
- "view.provider": "供应商",
2150
- "stats.today": "今日",
2151
- "stats.week": "本周",
2152
- "stats.month": "本月",
2153
- "stats.all": "累计",
2154
- "stats.count": "{count} 次",
2155
- "model.top5": "Top 5 模型",
2156
- "model.top5.hint": "按 Token 用量排序",
2157
- "model.winner": "当日最常用模型",
2158
- "model.none": "暂无模型数据",
2159
- "model.tokens": "{count} tokens",
2160
- "provider.top5": "Top 5 供应商",
2161
- "provider.winner": "当日最常用供应商",
2162
- "common.tokens": "tokens"
2163
- };
2164
- const en = {
2165
- "nav": "Token Pulse",
2166
- "heatmap.title": "Token Pulse",
2167
- "heatmap.subtitle": "Local token usage from session logs, bucketed by Asia/Shanghai, no network or billing. Synced once then persisted, surviving session deletion.",
2168
- "heatmap.empty": "No usage yet. Start a conversation and the heatmap will appear here.",
2169
- "heatmap.legend.less": "Less",
2170
- "heatmap.legend.more": "More",
2171
- "heatmap.tooltip.tokens": "{count} tokens",
2172
- "heatmap.tooltip.none": "No usage",
2173
- "heatmap.refresh": "Refresh",
2174
- "heatmap.refreshing": "Refreshing…",
2175
- "heatmap.lastRefresh": "Last refresh: {time}",
2176
- "heatmap.autoRefresh": "Auto refresh",
2177
- "heatmap.autoRefresh.off": "Off",
2178
- "heatmap.autoRefresh.hint": "Auto refresh interval (minutes)",
2179
- "heatmap.reset": "Reset history",
2180
- "heatmap.reset.confirm": "Reset all historical data? This cannot be undone.",
2181
- "heatmap.reset.done": "Reset done",
2182
- "view.week": "Week",
2183
- "view.month": "Month",
2184
- "view.quarter": "Quarter",
2185
- "view.year": "Year",
2186
- "view.model": "Model",
2187
- "view.provider": "Provider",
2188
- "stats.today": "Today",
2189
- "stats.week": "This week",
2190
- "stats.month": "This month",
2191
- "stats.all": "All time",
2192
- "stats.count": "{count} turns",
2193
- "model.top5": "Top 5 Models",
2194
- "model.top5.hint": "By token usage",
2195
- "model.winner": "Daily winner (model)",
2196
- "model.none": "No model data",
2197
- "model.tokens": "{count} tokens",
2198
- "provider.top5": "Top 5 Providers",
2199
- "provider.winner": "Daily winner (provider)",
2200
- "common.tokens": "tokens"
2201
- };
2202
- //#endregion
2203
2792
  //#region src/client/index.ts
2204
2793
  const NS = "dsh-token-pulse";
2205
2794
  const inject = ["locale", "slots"];