@linxiraos/pi-stats 1.1.27 → 1.2.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +10 -7
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +5 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +12 -0
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +4 -4
  51. package/src/aggregator.ts +28 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +73 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +87 -71
  90. package/src/client/routes/OverviewRoute.tsx +107 -62
  91. package/src/client/routes/ProjectsRoute.tsx +71 -45
  92. package/src/client/routes/ProvidersRoute.tsx +149 -113
  93. package/src/client/routes/RequestsRoute.tsx +68 -45
  94. package/src/client/routes/ToolsRoute.tsx +105 -92
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +4 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/live.ts +36 -11
  115. package/src/locale.ts +29 -0
  116. package/src/parser.ts +37 -72
  117. package/src/port-conflict.ts +1 -1
  118. package/src/prompts/judge/frustration.json +19 -0
  119. package/src/rollup.ts +41 -20
  120. package/src/server.ts +67 -27
  121. package/src/shared-types.ts +7 -0
  122. package/src/trace.ts +54 -40
  123. package/src/types.ts +12 -0
  124. package/src/user-metrics.ts +220 -6
package/src/client/api.ts CHANGED
@@ -45,12 +45,34 @@ async function readErrorMessage(res: Response, endpoint: string): Promise<string
45
45
  return `HTTP error ${res.status} on ${endpoint}`;
46
46
  }
47
47
 
48
- async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
49
- const res = await fetch(endpoint, options);
48
+ /**
49
+ * Response validators keyed by the parsed body they describe. Revalidating a
50
+ * body sends its ETag; the entry dies with the body when callers drop it.
51
+ */
52
+ const validators = new WeakMap<object, string>();
53
+
54
+ /**
55
+ * Fetch `endpoint` as JSON, revalidating `previous` when it came from an
56
+ * ETag-bearing response: a 304 resolves to `previous` itself (same reference,
57
+ * no re-parse).
58
+ */
59
+ async function fetchJson<T>(endpoint: string, options?: RequestInit, previous?: T): Promise<T> {
60
+ const etag = typeof previous === "object" && previous !== null ? validators.get(previous) : undefined;
61
+ let init = options;
62
+ if (etag) {
63
+ const headers = new Headers(options?.headers);
64
+ headers.set("If-None-Match", etag);
65
+ init = { ...options, headers };
66
+ }
67
+ const res = await fetch(endpoint, init);
68
+ if (res.status === 304 && etag) return previous as T;
50
69
  if (!res.ok) {
51
70
  throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
52
71
  }
53
- return res.json() as Promise<T>;
72
+ const data = (await res.json()) as T;
73
+ const nextEtag = res.headers.get("ETag");
74
+ if (nextEtag && typeof data === "object" && data !== null) validators.set(data, nextEtag);
75
+ return data;
54
76
  }
55
77
 
56
78
  /**
@@ -182,8 +204,21 @@ export async function getSessions(limit = 100, q?: string, signal?: AbortSignal)
182
204
  return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
183
205
  }
184
206
 
185
- export async function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace> {
186
- return fetchJson<SessionTrace>(`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`, { signal });
207
+ /**
208
+ * Pass the last trace for `file` as `previous` to revalidate it: an unchanged
209
+ * trace resolves to that same object without downloading or parsing the body.
210
+ */
211
+ export async function getSessionTrace(
212
+ file: string,
213
+ signal?: AbortSignal,
214
+ previous?: SessionTrace,
215
+ ): Promise<SessionTrace> {
216
+ // `no-store`: validation is ours (above); the browser cache would only keep a second multi-MB copy.
217
+ return fetchJson<SessionTrace>(
218
+ `${API_BASE}/session/trace?file=${encodeURIComponent(file)}`,
219
+ { signal, cache: "no-store" },
220
+ previous,
221
+ );
187
222
  }
188
223
 
189
224
  /** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
@@ -1,12 +1,13 @@
1
1
  import { RefreshCw } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { formatInteger } from "../data/formatters";
4
- import { useLive } from "../data/live";
4
+ import { t } from "../i18n";
5
+ import { useLiveStatus } from "../data/live";
5
6
  import { Dot } from "../ui";
6
7
 
7
8
  /** Topbar ingest status; click to sync now. */
8
9
  export function LiveChip() {
9
- const { sync, indexingHours, connected, requestSync } = useLive();
10
+ const { sync, indexingHours, connected, requestSync } = useLiveStatus();
10
11
  const now = useNow(sync.phase === "idle" ? 15_000 : null);
11
12
 
12
13
  let body: ReactNode;
@@ -15,15 +16,15 @@ export function LiveChip() {
15
16
  body = (
16
17
  <>
17
18
  <Dot tone="warn" />
18
- <span>Reconnecting</span>
19
+ <span>{t("shell.reconnecting")}</span>
19
20
  </>
20
21
  );
21
- title = "Lost the live connection to the stats server; retrying";
22
+ title = t("shell.lost-connection");
22
23
  } else if (sync.phase === "syncing") {
23
24
  body = (
24
25
  <>
25
26
  <RefreshCw size={13} className="spin" />
26
- <span>Syncing</span>
27
+ <span>{t("shell.syncing")}</span>
27
28
  {sync.total > 0 && (
28
29
  <span className="live-chip-count">
29
30
  {formatInteger(sync.current)}/{formatInteger(sync.total)}
@@ -31,33 +32,33 @@ export function LiveChip() {
31
32
  )}
32
33
  </>
33
34
  );
34
- title = "Ingesting session files";
35
+ title = t("shell.ingesting");
35
36
  } else if (sync.phase === "error") {
36
37
  body = (
37
38
  <>
38
39
  <Dot tone="bad" />
39
- <span>Sync failed</span>
40
+ <span>{t("shell.sync-failed")}</span>
40
41
  </>
41
42
  );
42
- title = `${sync.error ?? "Sync failed"} — click to retry`;
43
+ title = `${sync.error ?? t("shell.sync-failed")} — click to retry`;
43
44
  } else if (indexingHours > INDEXING_VISIBLE_HOURS) {
44
45
  body = (
45
46
  <>
46
47
  <RefreshCw size={13} className="spin" />
47
- <span>Indexing</span>
48
+ <span>{t("shell.indexing")}</span>
48
49
  <span className="live-chip-count">{formatInteger(indexingHours)}h left</span>
49
50
  </>
50
51
  );
51
- title = "Building hourly rollups; older ranges fill in as it goes";
52
+ title = t("shell.building-rollups");
52
53
  } else {
53
54
  body = (
54
55
  <>
55
56
  <Dot tone="live" pulse />
56
- <span>Live</span>
57
+ <span>{t("shell.live")}</span>
57
58
  {sync.lastSyncedAt !== null && <span className="live-chip-count">{ago(now - sync.lastSyncedAt)}</span>}
58
59
  </>
59
60
  );
60
- title = "Click to sync session files now";
61
+ title = t("shell.sync-now");
61
62
  }
62
63
 
63
64
  return (
@@ -89,7 +90,7 @@ function useNow(intervalMs: number | null): number {
89
90
 
90
91
  function ago(ms: number): string {
91
92
  const s = Math.max(0, Math.round(ms / 1000));
92
- if (s < 45) return "just now";
93
+ if (s < 45) return t("shell.just-now");
93
94
  const m = Math.round(s / 60);
94
95
  if (m < 60) return `${m}m ago`;
95
96
  const h = Math.round(m / 60);
@@ -1,7 +1,8 @@
1
1
  import { Menu } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { rangeMeta, TIME_RANGES } from "../data/range";
4
- import { useLive } from "../data/live";
4
+ import { t } from "../i18n";
5
+ import { useLiveStatus } from "../data/live";
5
6
  import type { TimeRange } from "../types";
6
7
  import { Segmented } from "../ui";
7
8
  import { LiveChip } from "./LiveChip";
@@ -18,8 +19,8 @@ export interface ShellProps {
18
19
 
19
20
  const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
20
21
  value: range,
21
- label: rangeMeta(range).label,
22
- title: `${rangeMeta(range).windowLabel} (${i + 1})`,
22
+ label: t(rangeMeta(range).labelKey),
23
+ title: `${t(rangeMeta(range).windowKey)} (${i + 1})`,
23
24
  }));
24
25
 
25
26
  /**
@@ -30,7 +31,7 @@ const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
30
31
  export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
31
32
  const [menuOpen, setMenuOpen] = useState(false);
32
33
  const scrolled = useScrolled();
33
- const { sync } = useLive();
34
+ const { sync } = useLiveStatus();
34
35
  useShortcuts(onSectionChange, onRangeChange);
35
36
 
36
37
  const go = (next: DashboardSection) => {
@@ -51,7 +52,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
51
52
  data-variant="ghost"
52
53
  data-icon="true"
53
54
  onClick={() => setMenuOpen(true)}
54
- aria-label="Open navigation"
55
+ aria-label={t("nav.open-navigation")}
55
56
  >
56
57
  <Menu size={16} />
57
58
  </button>
@@ -78,14 +79,19 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
78
79
  </svg>
79
80
  <span>zeta</span>
80
81
  <span className="topbar-slash">/</span>
81
- <span className="topbar-title">stats</span>
82
+ <span className="topbar-title">{t("nav.breadcrumb-stats")}</span>
82
83
  </div>
83
84
  <div className="topbar-spacer" />
84
85
  <div className="topbar-actions">
85
86
  <span className="topbar-hide-narrow">
86
87
  <LiveChip />
87
88
  </span>
88
- <Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
89
+ <Segmented
90
+ options={RANGE_OPTIONS}
91
+ value={range}
92
+ onChange={onRangeChange}
93
+ aria-label={t("common.time-range")}
94
+ />
89
95
  <ThemeToggle />
90
96
  </div>
91
97
  {progress !== undefined && (
@@ -100,10 +106,10 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
100
106
  </header>
101
107
 
102
108
  {menuOpen && <div className="drawer-scrim" onClick={() => setMenuOpen(false)} role="presentation" />}
103
- <nav className="sidebar" data-open={menuOpen} aria-label="Pages">
109
+ <nav className="sidebar" data-open={menuOpen} aria-label={t("nav.pages")}>
104
110
  {NAV.map(group => (
105
- <div key={group.heading} className="nav-group">
106
- <div className="nav-heading">{group.heading}</div>
111
+ <div key={group.headingKey} className="nav-group">
112
+ <div className="nav-heading">{t(group.headingKey)}</div>
107
113
  {group.items.map(item => (
108
114
  <button
109
115
  key={item.id}
@@ -114,7 +120,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
114
120
  onClick={() => go(item.id)}
115
121
  >
116
122
  <item.icon size={15} />
117
- <span className="nav-row-label">{item.label}</span>
123
+ <span className="nav-row-label">{t(item.labelKey)}</span>
118
124
  <kbd>G {item.hotkey.toUpperCase()}</kbd>
119
125
  </button>
120
126
  ))}
@@ -122,10 +128,10 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
122
128
  ))}
123
129
  <div className="sidebar-foot micro">
124
130
  <span>
125
- <span className="kbd">1</span>–<span className="kbd">6</span> range
131
+ <span className="kbd">1</span>–<span className="kbd">6</span> {t("shell.range-suffix")}
126
132
  </span>
127
133
  <span>
128
- <span className="kbd">G</span> then letter to jump
134
+ <span className="kbd">G</span> {t("shell.jump-hint")}
129
135
  </span>
130
136
  </div>
131
137
  </nav>
@@ -1,5 +1,6 @@
1
1
  import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
2
2
  import { type ThemePreference, useThemePreference } from "../useSystemTheme";
3
+ import { t } from "../i18n";
3
4
 
4
5
  const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
5
6
  system: "light",
@@ -14,9 +15,9 @@ const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
14
15
  };
15
16
 
16
17
  const PREFERENCE_LABEL: Record<ThemePreference, string> = {
17
- system: "System theme",
18
- light: "Light theme",
19
- dark: "Dark theme",
18
+ system: t("shell.system-theme"),
19
+ light: t("shell.light-theme"),
20
+ dark: t("shell.dark-theme"),
20
21
  };
21
22
 
22
23
  /** Cycles system → light → dark. */
@@ -12,6 +12,7 @@ import {
12
12
  SquareChartGantt,
13
13
  Wrench,
14
14
  } from "lucide-react";
15
+ import type { MessageKey } from "../i18n";
15
16
 
16
17
  export type DashboardSection =
17
18
  | "overview"
@@ -28,43 +29,45 @@ export type DashboardSection =
28
29
 
29
30
  export interface NavItem {
30
31
  id: DashboardSection;
31
- label: string;
32
+ /** Message key of the sidebar label; render through `t`. */
33
+ labelKey: MessageKey;
32
34
  icon: LucideIcon;
33
35
  /** Second key of the `g <key>` jump shortcut. */
34
36
  hotkey: string;
35
37
  }
36
38
 
37
39
  export interface NavGroup {
38
- heading: string;
40
+ /** Message key of the group heading; render through `t`. */
41
+ headingKey: MessageKey;
39
42
  items: readonly NavItem[];
40
43
  }
41
44
 
42
45
  /** Sidebar structure; the order here is the order on screen. */
43
46
  export const NAV: readonly NavGroup[] = [
44
47
  {
45
- heading: "Usage",
48
+ headingKey: "nav.usage",
46
49
  items: [
47
- { id: "overview", label: "Overview", icon: LayoutGrid, hotkey: "o" },
48
- { id: "models", label: "Models", icon: Cpu, hotkey: "m" },
49
- { id: "providers", label: "Providers", icon: PlugZap, hotkey: "p" },
50
- { id: "costs", label: "Costs", icon: Coins, hotkey: "c" },
50
+ { id: "overview", labelKey: "nav.overview", icon: LayoutGrid, hotkey: "o" },
51
+ { id: "models", labelKey: "nav.models", icon: Cpu, hotkey: "m" },
52
+ { id: "providers", labelKey: "nav.providers", icon: PlugZap, hotkey: "p" },
53
+ { id: "costs", labelKey: "nav.costs", icon: Coins, hotkey: "c" },
51
54
  ],
52
55
  },
53
56
  {
54
- heading: "Activity",
57
+ headingKey: "nav.activity",
55
58
  items: [
56
- { id: "requests", label: "Requests", icon: Activity, hotkey: "r" },
57
- { id: "errors", label: "Errors", icon: CircleAlert, hotkey: "e" },
58
- { id: "traces", label: "Traces", icon: SquareChartGantt, hotkey: "t" },
59
+ { id: "requests", labelKey: "nav.requests", icon: Activity, hotkey: "r" },
60
+ { id: "errors", labelKey: "nav.errors", icon: CircleAlert, hotkey: "e" },
61
+ { id: "traces", labelKey: "nav.traces", icon: SquareChartGantt, hotkey: "t" },
59
62
  ],
60
63
  },
61
64
  {
62
- heading: "Insights",
65
+ headingKey: "nav.insights",
63
66
  items: [
64
- { id: "tools", label: "Tools", icon: Wrench, hotkey: "l" },
65
- { id: "frustration", label: "Frustration", icon: Frown, hotkey: "f" },
66
- { id: "projects", label: "Projects", icon: FolderGit2, hotkey: "j" },
67
- { id: "gain", label: "Gain", icon: Sparkles, hotkey: "g" },
67
+ { id: "tools", labelKey: "nav.tools", icon: Wrench, hotkey: "l" },
68
+ { id: "frustration", labelKey: "nav.frustration", icon: Frown, hotkey: "f" },
69
+ { id: "projects", labelKey: "nav.projects", icon: FolderGit2, hotkey: "j" },
70
+ { id: "gain", labelKey: "nav.gain", icon: Sparkles, hotkey: "g" },
68
71
  ],
69
72
  },
70
73
  ];
@@ -1,25 +1,29 @@
1
- import { format, formatDistanceToNow } from "@linxiraos/pi-utils/dates";
1
+ import { getLocale, t } from "../i18n";
2
2
  import type { MessageStats } from "../types";
3
3
 
4
4
  /**
5
- * Every number on the dashboard uses one fixed locale. Labels, percents
6
- * (`toFixed`), durations and dates are English, so following the browser
7
- * locale mixed conventions (e.g. `1,4 Mr` beside `97.0%` on a tr-TR browser).
5
+ * One locale drives every number and date, and it is the *product's* — the
6
+ * host CLI's language injected into the page, not the browser's. Mixing
7
+ * conventions (e.g. `1,4 Mr` beside `97.0%` on a tr-TR browser) stays
8
+ * impossible; the difference from the pre-i18n build is only that a `zh-CN`
9
+ * dashboard now formats in `zh-CN` throughout.
8
10
  */
9
- const NUMBER_LOCALE = "en-US";
11
+ function numberLocale(): string {
12
+ return getLocale();
13
+ }
10
14
 
11
15
  export function formatInteger(value: number): string {
12
- return value.toLocaleString(NUMBER_LOCALE);
16
+ return value.toLocaleString(numberLocale());
13
17
  }
14
18
 
15
19
  export function formatCompact(value: number): string {
16
- return value.toLocaleString(NUMBER_LOCALE, { notation: "compact" });
20
+ return value.toLocaleString(numberLocale(), { notation: "compact" });
17
21
  }
18
22
 
19
23
  export function formatCost(value: number, digits?: number): string {
20
24
  if (value === 0) return "$0";
21
25
  const fractionDigits = digits !== undefined ? digits : value > 0 && value < 0.01 ? 4 : 2;
22
- return `$${value.toLocaleString(NUMBER_LOCALE, {
26
+ return `$${value.toLocaleString(numberLocale(), {
23
27
  minimumFractionDigits: fractionDigits,
24
28
  maximumFractionDigits: fractionDigits,
25
29
  })}`;
@@ -83,18 +87,74 @@ export function formatTokensPerSecond(value: number | null): string {
83
87
  return value.toFixed(1);
84
88
  }
85
89
 
86
- export function formatRelativeTime(timestamp: number): string {
87
- return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
90
+ const RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
91
+ ["second", 1_000],
92
+ ["minute", 60_000],
93
+ ["hour", 3_600_000],
94
+ ["day", 86_400_000],
95
+ ["month", 2_629_800_000],
96
+ ["year", 31_557_600_000],
97
+ ];
98
+
99
+ const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
100
+ const dateFormatters = new Map<string, Intl.DateTimeFormat>();
101
+
102
+ /** "2 minutes ago" / "2 分钟前" — the ladder picks the largest unit the delta reaches. */
103
+ export function formatRelativeTime(timestamp: number, now = Date.now()): string {
104
+ const locale = getLocale();
105
+ let formatter = relativeFormatters.get(locale);
106
+ if (!formatter) {
107
+ formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
108
+ relativeFormatters.set(locale, formatter);
109
+ }
110
+ const delta = timestamp - now;
111
+ let unitIndex = 0;
112
+ for (let i = 1; i < RELATIVE_UNITS.length; i++) {
113
+ if (Math.abs(delta) >= RELATIVE_UNITS[i][1]) unitIndex = i;
114
+ }
115
+ const [unit, ms] = RELATIVE_UNITS[unitIndex];
116
+ return formatter.format(Math.round(delta / ms), unit);
117
+ }
118
+
119
+ /** Local month/day plus minute ("Sep 28 14:03" / "9月28日 14:03") for chart range captions. */
120
+ export function formatDateTimeShort(timestamp: number): string {
121
+ const locale = getLocale();
122
+ const key = `${locale}:mdhm`;
123
+ let formatter = dateFormatters.get(key);
124
+ if (!formatter) {
125
+ formatter = new Intl.DateTimeFormat(locale, {
126
+ month: "short",
127
+ day: "numeric",
128
+ hour: "2-digit",
129
+ minute: "2-digit",
130
+ hourCycle: "h23",
131
+ });
132
+ dateFormatters.set(key, formatter);
133
+ }
134
+ return formatter.format(timestamp);
88
135
  }
89
136
 
90
- /** Absolute local date and time to the second ("Sep 28, 14:03:22"). */
137
+ /** Absolute local date and time to the second ("Sep 28, 14:03:22" / "9月28日 14:03:22"). */
91
138
  export function formatTimestamp(timestamp: number): string {
92
- return format(new Date(timestamp), "MMM d, HH:mm:ss");
139
+ const locale = getLocale();
140
+ let formatter = dateFormatters.get(locale);
141
+ if (!formatter) {
142
+ formatter = new Intl.DateTimeFormat(locale, {
143
+ month: "short",
144
+ day: "numeric",
145
+ hour: "2-digit",
146
+ minute: "2-digit",
147
+ second: "2-digit",
148
+ hourCycle: "h23",
149
+ });
150
+ dateFormatters.set(locale, formatter);
151
+ }
152
+ return formatter.format(timestamp);
93
153
  }
94
154
 
95
155
  /** Project folder as stored by ingest (`/work-pi/`), without the slashes. */
96
156
  export function formatFolder(folder: string): string {
97
- return folder.replace(/^\/+|\/+$/g, "") || "(root)";
157
+ return folder.replace(/^\/+|\/+$/g, "") || t("common.root");
98
158
  }
99
159
 
100
160
  export function formatBytes(value: number): string {
@@ -3,21 +3,17 @@
3
3
  * version every query revalidates against, background ingest progress, and
4
4
  * the rollup indexing backlog.
5
5
  *
6
- * Any component can `useLive()`; queries (see `useQuery`) refetch whenever
7
- * `version` advances, so rows ingested in the background repaint the active
8
- * page without a manual refresh.
6
+ * The state lives in a module store read through narrow selectors, so a
7
+ * progress tick only re-renders what shows progress: `useLiveVersion()` for
8
+ * queries (see `useQuery`), `useLiveStatus()` for the chrome.
9
9
  */
10
10
 
11
- import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
11
+ import { type ReactNode, useEffect, useSyncExternalStore } from "react";
12
12
  import * as api from "../api";
13
13
  import type { LiveStatus, LiveSyncStatus } from "../types";
14
14
 
15
- export interface LiveState {
16
- /**
17
- * Client-side data version; bumps whenever the server's version changes
18
- * (including after reconnecting to a restarted server).
19
- */
20
- version: number;
15
+ /** Ingest status shown by the chrome. */
16
+ export interface LiveStatusView {
21
17
  sync: LiveSyncStatus;
22
18
  /** Hours whose rollups are still being built; wide ranges may be incomplete. */
23
19
  indexingHours: number;
@@ -36,50 +32,94 @@ const IDLE_SYNC: LiveSyncStatus = {
36
32
  error: null,
37
33
  };
38
34
 
39
- const LiveContext = createContext<LiveState>({
40
- version: 0,
41
- sync: IDLE_SYNC,
42
- indexingHours: 0,
43
- connected: false,
44
- requestSync: () => {},
45
- });
35
+ function requestSync(): void {
36
+ api.requestSync().catch(() => {});
37
+ }
46
38
 
47
- export function LiveProvider({ children }: { children: ReactNode }) {
48
- const [status, setStatus] = useState<{ server: LiveStatus | null; version: number }>({ server: null, version: 0 });
49
- const [connected, setConnected] = useState(false);
39
+ let lastServerVersion: number | null = null;
40
+ /** Distinct server versions seen (including across server restarts). */
41
+ let seenVersions = 0;
42
+ /**
43
+ * Client-side data version; bumps whenever the server's version changes.
44
+ * Lags `seenVersions` by one so the first stream message doesn't refetch what
45
+ * just loaded.
46
+ */
47
+ let version = 0;
48
+ let status: LiveStatusView = { sync: IDLE_SYNC, indexingHours: 0, connected: false, requestSync };
49
+ const listeners = new Set<() => void>();
50
+
51
+ function subscribe(listener: () => void): () => void {
52
+ listeners.add(listener);
53
+ return () => listeners.delete(listener);
54
+ }
55
+
56
+ function emit(): void {
57
+ for (const listener of listeners) listener();
58
+ }
59
+
60
+ function sameSync(a: LiveSyncStatus, b: LiveSyncStatus): boolean {
61
+ return (
62
+ a.phase === b.phase &&
63
+ a.current === b.current &&
64
+ a.total === b.total &&
65
+ a.processed === b.processed &&
66
+ a.lastSyncedAt === b.lastSyncedAt &&
67
+ a.error === b.error
68
+ );
69
+ }
70
+
71
+ function receive(next: LiveStatus): void {
72
+ if (lastServerVersion !== next.version) {
73
+ lastServerVersion = next.version;
74
+ seenVersions++;
75
+ }
76
+ const nextVersion = Math.max(0, seenVersions - 1);
77
+ const statusChanged = next.indexingHours !== status.indexingHours || !sameSync(next.sync, status.sync);
78
+ if (!statusChanged && nextVersion === version) return;
79
+ version = nextVersion;
80
+ if (statusChanged) status = { ...status, sync: next.sync, indexingHours: next.indexingHours };
81
+ emit();
82
+ }
50
83
 
84
+ function setConnected(connected: boolean): void {
85
+ if (connected === status.connected) return;
86
+ status = { ...status, connected };
87
+ emit();
88
+ }
89
+
90
+ const getStatus = (): LiveStatusView => status;
91
+ const getVersion = (): number => version;
92
+ const getDisabledVersion = (): number => -1;
93
+
94
+ /** Current data version, read outside render (e.g. when a fetch starts). */
95
+ export function currentLiveVersion(): number {
96
+ return version;
97
+ }
98
+
99
+ /** Owns the `/api/events` stream; mount once at the app root. */
100
+ export function LiveProvider({ children }: { children: ReactNode }) {
51
101
  useEffect(() => {
52
102
  const source = new EventSource("/api/events");
53
103
  source.onopen = () => setConnected(true);
54
104
  source.onerror = () => setConnected(false);
55
- source.onmessage = event => {
56
- const next = JSON.parse(event.data) as LiveStatus;
57
- setStatus(prev => ({
58
- server: next,
59
- version: prev.server && prev.server.version === next.version ? prev.version : prev.version + 1,
60
- }));
105
+ source.onmessage = event => receive(JSON.parse(event.data) as LiveStatus);
106
+ return () => {
107
+ source.close();
108
+ setConnected(false);
61
109
  };
62
- return () => source.close();
63
- }, []);
64
-
65
- const requestSync = useCallback(() => {
66
- api.requestSync().catch(() => {});
67
110
  }, []);
111
+ return children;
112
+ }
68
113
 
69
- const value = useMemo<LiveState>(
70
- () => ({
71
- // Start at 0 so the first stream message (version 1) doesn't refetch what just loaded.
72
- version: Math.max(0, status.version - 1),
73
- sync: status.server?.sync ?? IDLE_SYNC,
74
- indexingHours: status.server?.indexingHours ?? 0,
75
- connected,
76
- requestSync,
77
- }),
78
- [status, connected, requestSync],
79
- );
80
- return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
114
+ /** Ingest status for the chrome; data-version bumps alone don't re-render. */
115
+ export function useLiveStatus(): LiveStatusView {
116
+ return useSyncExternalStore(subscribe, getStatus);
81
117
  }
82
118
 
83
- export function useLive(): LiveState {
84
- return useContext(LiveContext);
119
+ /**
120
+ * Data version for queries. Disabled (hidden) consumers read a constant -1 so
121
+ * live traffic never re-renders them.
122
+ */
123
+ export function useLiveVersion(enabled = true): number {
124
+ return useSyncExternalStore(subscribe, enabled ? getVersion : getDisabledVersion);
85
125
  }