@cryptiklemur/lattice 1.6.0 → 1.7.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.
@@ -32,6 +32,10 @@ import { CacheEfficiencyChart } from "./charts/CacheEfficiencyChart";
32
32
  import { ResponseTimeScatter } from "./charts/ResponseTimeScatter";
33
33
  import { ContextUtilizationChart } from "./charts/ContextUtilizationChart";
34
34
  import { TokenSankeyChart } from "./charts/TokenSankeyChart";
35
+ import { ActivityCalendar } from "./charts/ActivityCalendar";
36
+ import { HourlyHeatmap } from "./charts/HourlyHeatmap";
37
+ import { SessionTimeline } from "./charts/SessionTimeline";
38
+ import { DailySummaryCards } from "./charts/DailySummaryCards";
35
39
 
36
40
  export function AnalyticsView() {
37
41
  var analytics = useAnalytics();
@@ -107,6 +111,31 @@ export function AnalyticsView() {
107
111
  </ChartErrorBoundary>
108
112
  </ChartCard>
109
113
  </div>
114
+
115
+ <ChartCard title="Activity Calendar">
116
+ <ChartErrorBoundary name="Calendar">
117
+ <ActivityCalendar data={analytics.data.activityCalendar} />
118
+ </ChartErrorBoundary>
119
+ </ChartCard>
120
+
121
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
122
+ <ChartCard title="Hourly Activity">
123
+ <ChartErrorBoundary name="HourlyHeatmap">
124
+ <HourlyHeatmap data={analytics.data.hourlyHeatmap} />
125
+ </ChartErrorBoundary>
126
+ </ChartCard>
127
+ <ChartCard title="Session Timeline">
128
+ <ChartErrorBoundary name="Timeline">
129
+ <SessionTimeline data={analytics.data.sessionTimeline} />
130
+ </ChartErrorBoundary>
131
+ </ChartCard>
132
+ </div>
133
+
134
+ <ChartCard title="Daily Summary">
135
+ <ChartErrorBoundary name="DailySummary">
136
+ <DailySummaryCards data={analytics.data.dailySummaries} />
137
+ </ChartErrorBoundary>
138
+ </ChartCard>
110
139
  </div>
111
140
  )}
112
141
 
@@ -0,0 +1,185 @@
1
+ import { useState } from "react";
2
+
3
+ interface CalendarDatum {
4
+ date: string;
5
+ count: number;
6
+ tokens: number;
7
+ cost: number;
8
+ }
9
+
10
+ interface ActivityCalendarProps {
11
+ data: CalendarDatum[];
12
+ }
13
+
14
+ var CELL_SIZE = 11;
15
+ var CELL_GAP = 2;
16
+ var CELL_STEP = CELL_SIZE + CELL_GAP;
17
+ var DAY_LABEL_WIDTH = 28;
18
+ var MONTH_LABEL_HEIGHT = 14;
19
+
20
+ var INTENSITY_OPACITIES = [0.05, 0.15, 0.3, 0.5, 0.8];
21
+ var PRIMARY_COLOR = "oklch(55% 0.25 280)";
22
+
23
+ var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
24
+ var DAY_LABELS = [
25
+ { index: 1, label: "Mon" },
26
+ { index: 3, label: "Wed" },
27
+ { index: 5, label: "Fri" },
28
+ ];
29
+
30
+ function getIntensity(count: number, maxCount: number): number {
31
+ if (count === 0 || maxCount === 0) return -1;
32
+ var ratio = count / maxCount;
33
+ if (ratio <= 0.2) return 0;
34
+ if (ratio <= 0.4) return 1;
35
+ if (ratio <= 0.6) return 2;
36
+ if (ratio <= 0.8) return 3;
37
+ return 4;
38
+ }
39
+
40
+ function parseDateParts(dateStr: string): { year: number; month: number; day: number } {
41
+ var parts = dateStr.split("-");
42
+ return { year: parseInt(parts[0], 10), month: parseInt(parts[1], 10) - 1, day: parseInt(parts[2], 10) };
43
+ }
44
+
45
+ export function ActivityCalendar({ data }: ActivityCalendarProps) {
46
+ var [hover, setHover] = useState<{ x: number; y: number; datum: CalendarDatum } | null>(null);
47
+
48
+ if (!data || data.length === 0) {
49
+ return (
50
+ <div className="flex items-center justify-center h-[120px] text-base-content/25 font-mono text-[11px]">
51
+ No data
52
+ </div>
53
+ );
54
+ }
55
+
56
+ var dataMap = new Map<string, CalendarDatum>();
57
+ var maxCount = 0;
58
+ for (var i = 0; i < data.length; i++) {
59
+ dataMap.set(data[i].date, data[i]);
60
+ if (data[i].count > maxCount) maxCount = data[i].count;
61
+ }
62
+
63
+ var lastDate = new Date(data[data.length - 1].date + "T00:00:00");
64
+ var startDate = new Date(lastDate);
65
+ startDate.setDate(startDate.getDate() - 364);
66
+ var startDay = startDate.getDay();
67
+ if (startDay !== 0) {
68
+ startDate.setDate(startDate.getDate() - startDay);
69
+ }
70
+
71
+ var weeks: Array<Array<{ date: string; datum: CalendarDatum | undefined } | null>> = [];
72
+ var cursor = new Date(startDate);
73
+ var currentWeek: Array<{ date: string; datum: CalendarDatum | undefined } | null> = [];
74
+
75
+ while (cursor <= lastDate) {
76
+ var dow = cursor.getDay();
77
+ if (dow === 0 && currentWeek.length > 0) {
78
+ weeks.push(currentWeek);
79
+ currentWeek = [];
80
+ }
81
+ var key = cursor.getFullYear() + "-" + String(cursor.getMonth() + 1).padStart(2, "0") + "-" + String(cursor.getDate()).padStart(2, "0");
82
+ currentWeek.push({ date: key, datum: dataMap.get(key) });
83
+ cursor.setDate(cursor.getDate() + 1);
84
+ }
85
+ if (currentWeek.length > 0) {
86
+ while (currentWeek.length < 7) currentWeek.push(null);
87
+ weeks.push(currentWeek);
88
+ }
89
+
90
+ var monthLabels: Array<{ col: number; label: string }> = [];
91
+ var lastMonth = -1;
92
+ for (var wi = 0; wi < weeks.length; wi++) {
93
+ var firstCell = weeks[wi][0];
94
+ if (!firstCell) continue;
95
+ var parts = parseDateParts(firstCell.date);
96
+ if (parts.month !== lastMonth) {
97
+ monthLabels.push({ col: wi, label: MONTH_NAMES[parts.month] });
98
+ lastMonth = parts.month;
99
+ }
100
+ }
101
+
102
+ var svgWidth = DAY_LABEL_WIDTH + weeks.length * CELL_STEP;
103
+ var svgHeight = MONTH_LABEL_HEIGHT + 7 * CELL_STEP;
104
+
105
+ return (
106
+ <div className="relative overflow-x-auto">
107
+ <svg width={svgWidth} height={svgHeight} className="block">
108
+ {monthLabels.map(function (ml, idx) {
109
+ return (
110
+ <text
111
+ key={idx}
112
+ x={DAY_LABEL_WIDTH + ml.col * CELL_STEP}
113
+ y={10}
114
+ className="fill-base-content/30"
115
+ style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
116
+ >
117
+ {ml.label}
118
+ </text>
119
+ );
120
+ })}
121
+
122
+ {DAY_LABELS.map(function (dl) {
123
+ return (
124
+ <text
125
+ key={dl.index}
126
+ x={0}
127
+ y={MONTH_LABEL_HEIGHT + dl.index * CELL_STEP + CELL_SIZE - 1}
128
+ className="fill-base-content/30"
129
+ style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
130
+ >
131
+ {dl.label}
132
+ </text>
133
+ );
134
+ })}
135
+
136
+ {weeks.map(function (week, col) {
137
+ return week.map(function (cell, row) {
138
+ if (!cell) return null;
139
+ var intensity = getIntensity(cell.datum?.count || 0, maxCount);
140
+ var x = DAY_LABEL_WIDTH + col * CELL_STEP;
141
+ var y = MONTH_LABEL_HEIGHT + row * CELL_STEP;
142
+ var opacity = intensity >= 0 ? INTENSITY_OPACITIES[intensity] : 0.05;
143
+ var fillColor = intensity >= 0 ? PRIMARY_COLOR : undefined;
144
+
145
+ return (
146
+ <rect
147
+ key={cell.date}
148
+ x={x}
149
+ y={y}
150
+ width={CELL_SIZE}
151
+ height={CELL_SIZE}
152
+ rx={2}
153
+ ry={2}
154
+ fill={fillColor || "currentColor"}
155
+ opacity={opacity}
156
+ className={fillColor ? "" : "text-base-content/5"}
157
+ onMouseEnter={function (e) {
158
+ setHover({
159
+ x: e.clientX,
160
+ y: e.clientY,
161
+ datum: cell!.datum || { date: cell!.date, count: 0, tokens: 0, cost: 0 },
162
+ });
163
+ }}
164
+ onMouseLeave={function () { setHover(null); }}
165
+ />
166
+ );
167
+ });
168
+ })}
169
+ </svg>
170
+
171
+ {hover && (
172
+ <div
173
+ className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none"
174
+ style={{ left: hover.x + 12, top: hover.y - 40 }}
175
+ >
176
+ <p className="text-[10px] font-mono text-base-content/50">{hover.datum.date}</p>
177
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
178
+ <p><span className="text-base-content/40">sessions </span>{hover.datum.count}</p>
179
+ <p><span className="text-base-content/40">cost </span>${hover.datum.cost.toFixed(4)}</p>
180
+ </div>
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,83 @@
1
+ interface DailySummaryDatum {
2
+ date: string;
3
+ sessions: number;
4
+ cost: number;
5
+ tokens: number;
6
+ topTool: string;
7
+ modelMix: Record<string, number>;
8
+ }
9
+
10
+ interface DailySummaryCardsProps {
11
+ data: DailySummaryDatum[];
12
+ }
13
+
14
+ var MODEL_COLORS: Record<string, string> = {
15
+ opus: "oklch(55% 0.25 280)",
16
+ sonnet: "#a855f7",
17
+ haiku: "#22c55e",
18
+ other: "#f59e0b",
19
+ };
20
+
21
+ function formatCardDate(dateStr: string): string {
22
+ var d = new Date(dateStr + "T00:00:00");
23
+ var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
24
+ return days[d.getDay()] + " " + (d.getMonth() + 1) + "/" + d.getDate();
25
+ }
26
+
27
+ export function DailySummaryCards({ data }: DailySummaryCardsProps) {
28
+ if (!data || data.length === 0) {
29
+ return (
30
+ <div className="flex items-center justify-center h-[100px] text-base-content/25 font-mono text-[11px]">
31
+ No data
32
+ </div>
33
+ );
34
+ }
35
+
36
+ var reversed = data.slice().reverse();
37
+
38
+ return (
39
+ <div className="flex gap-3 overflow-x-auto snap-x snap-mandatory pb-2 scrollbar-thin">
40
+ {reversed.map(function (d) {
41
+ var mixEntries = Object.entries(d.modelMix);
42
+
43
+ return (
44
+ <div
45
+ key={d.date}
46
+ className="flex-shrink-0 snap-start rounded-lg bg-base-300 border border-base-content/5 px-3 py-2.5 w-[140px]"
47
+ >
48
+ <p className="text-[11px] font-mono font-bold text-base-content/60 mb-1.5">
49
+ {formatCardDate(d.date)}
50
+ </p>
51
+
52
+ <div className="text-[10px] font-mono text-base-content/50 space-y-0.5">
53
+ <p><span className="text-base-content/30">sessions </span>{d.sessions}</p>
54
+ <p><span className="text-base-content/30">cost </span>${d.cost.toFixed(2)}</p>
55
+ {d.topTool && (
56
+ <p className="truncate"><span className="text-base-content/30">tool </span>{d.topTool}</p>
57
+ )}
58
+ </div>
59
+
60
+ {mixEntries.length > 0 && (
61
+ <div className="mt-2 h-[4px] rounded-full overflow-hidden flex bg-base-content/5">
62
+ {mixEntries.map(function (entry) {
63
+ var model = entry[0];
64
+ var pct = entry[1];
65
+ if (pct <= 0) return null;
66
+ return (
67
+ <div
68
+ key={model}
69
+ style={{
70
+ width: (pct * 100) + "%",
71
+ backgroundColor: MODEL_COLORS[model] || MODEL_COLORS.other,
72
+ }}
73
+ />
74
+ );
75
+ })}
76
+ </div>
77
+ )}
78
+ </div>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,129 @@
1
+ import { useState } from "react";
2
+
3
+ interface HeatmapDatum {
4
+ day: number;
5
+ hour: number;
6
+ count: number;
7
+ }
8
+
9
+ interface HourlyHeatmapProps {
10
+ data: HeatmapDatum[];
11
+ }
12
+
13
+ var CELL_SIZE = 18;
14
+ var CELL_GAP = 2;
15
+ var CELL_STEP = CELL_SIZE + CELL_GAP;
16
+ var DAY_LABEL_WIDTH = 32;
17
+ var HOUR_LABEL_HEIGHT = 16;
18
+
19
+ var DAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
20
+ var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
21
+ var HOUR_LABELS = [0, 3, 6, 9, 12, 15, 18, 21];
22
+
23
+ var PRIMARY_COLOR = "oklch(55% 0.25 280)";
24
+
25
+ export function HourlyHeatmap({ data }: HourlyHeatmapProps) {
26
+ var [hover, setHover] = useState<{ x: number; y: number; day: string; hour: number; count: number } | null>(null);
27
+
28
+ if (!data || data.length === 0) {
29
+ return (
30
+ <div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
31
+ No data
32
+ </div>
33
+ );
34
+ }
35
+
36
+ var grid = new Map<string, number>();
37
+ var maxCount = 0;
38
+ for (var i = 0; i < data.length; i++) {
39
+ var key = data[i].day + ":" + data[i].hour;
40
+ grid.set(key, data[i].count);
41
+ if (data[i].count > maxCount) maxCount = data[i].count;
42
+ }
43
+
44
+ var svgWidth = DAY_LABEL_WIDTH + 24 * CELL_STEP;
45
+ var svgHeight = HOUR_LABEL_HEIGHT + 7 * CELL_STEP;
46
+
47
+ return (
48
+ <div className="relative overflow-x-auto">
49
+ <svg width={svgWidth} height={svgHeight} className="block">
50
+ {HOUR_LABELS.map(function (h) {
51
+ return (
52
+ <text
53
+ key={h}
54
+ x={DAY_LABEL_WIDTH + h * CELL_STEP + CELL_SIZE / 2}
55
+ y={11}
56
+ textAnchor="middle"
57
+ className="fill-base-content/30"
58
+ style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
59
+ >
60
+ {h}
61
+ </text>
62
+ );
63
+ })}
64
+
65
+ {DAY_ORDER.map(function (dayIdx, row) {
66
+ return (
67
+ <text
68
+ key={dayIdx}
69
+ x={0}
70
+ y={HOUR_LABEL_HEIGHT + row * CELL_STEP + CELL_SIZE - 3}
71
+ className="fill-base-content/30"
72
+ style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
73
+ >
74
+ {DAY_NAMES[dayIdx]}
75
+ </text>
76
+ );
77
+ })}
78
+
79
+ {DAY_ORDER.map(function (dayIdx, row) {
80
+ return Array.from({ length: 24 }, function (_, h) {
81
+ var cellKey = dayIdx + ":" + h;
82
+ var count = grid.get(cellKey) || 0;
83
+ var opacity = maxCount > 0 && count > 0 ? Math.max(0.1, count / maxCount) : 0.05;
84
+ var x = DAY_LABEL_WIDTH + h * CELL_STEP;
85
+ var y = HOUR_LABEL_HEIGHT + row * CELL_STEP;
86
+ var dayName = DAY_NAMES[dayIdx];
87
+
88
+ return (
89
+ <rect
90
+ key={cellKey}
91
+ x={x}
92
+ y={y}
93
+ width={CELL_SIZE}
94
+ height={CELL_SIZE}
95
+ rx={2}
96
+ ry={2}
97
+ fill={count > 0 ? PRIMARY_COLOR : "currentColor"}
98
+ opacity={opacity}
99
+ className={count > 0 ? "" : "text-base-content/5"}
100
+ onMouseEnter={function (e) {
101
+ setHover({
102
+ x: e.clientX,
103
+ y: e.clientY,
104
+ day: dayName,
105
+ hour: h,
106
+ count: count,
107
+ });
108
+ }}
109
+ onMouseLeave={function () { setHover(null); }}
110
+ />
111
+ );
112
+ });
113
+ })}
114
+ </svg>
115
+
116
+ {hover && (
117
+ <div
118
+ className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none"
119
+ style={{ left: hover.x + 12, top: hover.y - 40 }}
120
+ >
121
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
122
+ <p><span className="text-base-content/40">{hover.day} </span>{hover.hour}:00</p>
123
+ <p><span className="text-base-content/40">sessions </span>{hover.count}</p>
124
+ </div>
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,112 @@
1
+ import { useState } from "react";
2
+
3
+ interface TimelineDatum {
4
+ id: string;
5
+ title: string;
6
+ project: string;
7
+ start: number;
8
+ end: number;
9
+ cost: number;
10
+ }
11
+
12
+ interface SessionTimelineProps {
13
+ data: TimelineDatum[];
14
+ }
15
+
16
+ var ROW_HEIGHT = 16;
17
+ var ROW_GAP = 2;
18
+ var ROW_STEP = ROW_HEIGHT + ROW_GAP;
19
+ var LEFT_MARGIN = 8;
20
+ var RIGHT_MARGIN = 8;
21
+
22
+ var PROJECT_PALETTE = [
23
+ "oklch(55% 0.25 280)",
24
+ "#a855f7",
25
+ "#22c55e",
26
+ "#f59e0b",
27
+ "oklch(65% 0.2 240)",
28
+ "oklch(65% 0.25 25)",
29
+ "oklch(65% 0.25 150)",
30
+ "oklch(70% 0.2 60)",
31
+ ];
32
+
33
+ function formatTime(ts: number): string {
34
+ var d = new Date(ts);
35
+ return (d.getMonth() + 1) + "/" + d.getDate() + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
36
+ }
37
+
38
+ export function SessionTimeline({ data }: SessionTimelineProps) {
39
+ var [hover, setHover] = useState<{ x: number; y: number; datum: TimelineDatum } | null>(null);
40
+
41
+ if (!data || data.length === 0) {
42
+ return (
43
+ <div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
44
+ No data
45
+ </div>
46
+ );
47
+ }
48
+
49
+ var projects = Array.from(new Set(data.map(function (d) { return d.project; })));
50
+ function getColor(project: string): string {
51
+ var idx = projects.indexOf(project);
52
+ return PROJECT_PALETTE[idx % PROJECT_PALETTE.length];
53
+ }
54
+
55
+ var minTime = Infinity;
56
+ var maxTime = -Infinity;
57
+ for (var i = 0; i < data.length; i++) {
58
+ if (data[i].start < minTime) minTime = data[i].start;
59
+ if (data[i].end > maxTime) maxTime = data[i].end;
60
+ }
61
+ var timeRange = maxTime - minTime || 1;
62
+
63
+ var svgHeight = data.length * ROW_STEP;
64
+ var maxHeight = 300;
65
+
66
+ return (
67
+ <div className="relative overflow-y-auto overflow-x-hidden" style={{ maxHeight: maxHeight }}>
68
+ <svg width="100%" height={svgHeight} className="block" viewBox={"0 0 600 " + svgHeight} preserveAspectRatio="none">
69
+ {data.map(function (d, idx) {
70
+ var barWidth = 600 - LEFT_MARGIN - RIGHT_MARGIN;
71
+ var x1 = LEFT_MARGIN + ((d.start - minTime) / timeRange) * barWidth;
72
+ var x2 = LEFT_MARGIN + ((d.end - minTime) / timeRange) * barWidth;
73
+ var w = Math.max(x2 - x1, 3);
74
+ var y = idx * ROW_STEP;
75
+
76
+ return (
77
+ <rect
78
+ key={d.id}
79
+ x={x1}
80
+ y={y}
81
+ width={w}
82
+ height={ROW_HEIGHT}
83
+ rx={3}
84
+ ry={3}
85
+ fill={getColor(d.project)}
86
+ opacity={0.7}
87
+ onMouseEnter={function (e) {
88
+ setHover({ x: e.clientX, y: e.clientY, datum: d });
89
+ }}
90
+ onMouseLeave={function () { setHover(null); }}
91
+ />
92
+ );
93
+ })}
94
+ </svg>
95
+
96
+ {hover && (
97
+ <div
98
+ className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none max-w-[200px]"
99
+ style={{ left: hover.x + 12, top: hover.y - 50 }}
100
+ >
101
+ <p className="text-[10px] font-mono text-base-content/50 mb-1 truncate">{hover.datum.title}</p>
102
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
103
+ <p><span className="text-base-content/40">project </span>{hover.datum.project}</p>
104
+ <p><span className="text-base-content/40">start </span>{formatTime(hover.datum.start)}</p>
105
+ <p><span className="text-base-content/40">end </span>{formatTime(hover.datum.end)}</p>
106
+ <p><span className="text-base-content/40">cost </span>${hover.datum.cost.toFixed(4)}</p>
107
+ </div>
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -520,6 +520,124 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
520
520
 
521
521
  var tokenFlowSankey: AnalyticsPayload["tokenFlowSankey"] = { nodes: sankeyNodes, links: sankeyLinks };
522
522
 
523
+ var activityCalendarMap = new Map<string, { count: number; tokens: number; cost: number }>();
524
+ for (var aci = 0; aci < filtered.length; aci++) {
525
+ var acSess = filtered[aci];
526
+ var acDate = formatDate(acSess.endTime > 0 ? acSess.endTime : acSess.startTime);
527
+ var acEntry = activityCalendarMap.get(acDate);
528
+ if (!acEntry) {
529
+ acEntry = { count: 0, tokens: 0, cost: 0 };
530
+ activityCalendarMap.set(acDate, acEntry);
531
+ }
532
+ acEntry.count++;
533
+ acEntry.tokens += acSess.inputTokens + acSess.outputTokens;
534
+ acEntry.cost += acSess.cost;
535
+ }
536
+
537
+ var activityCalendar: AnalyticsPayload["activityCalendar"] = [];
538
+ if (dates.length > 0) {
539
+ var calStart = new Date(dates[0]);
540
+ var calEnd = new Date(dates[dates.length - 1]);
541
+ var calCursor = new Date(calStart);
542
+ while (calCursor <= calEnd) {
543
+ var calKey = formatDate(calCursor.getTime());
544
+ var calData = activityCalendarMap.get(calKey);
545
+ activityCalendar.push({
546
+ date: calKey,
547
+ count: calData ? calData.count : 0,
548
+ tokens: calData ? calData.tokens : 0,
549
+ cost: calData ? calData.cost : 0,
550
+ });
551
+ calCursor.setDate(calCursor.getDate() + 1);
552
+ }
553
+ }
554
+
555
+ var hourlyHeatmap: AnalyticsPayload["hourlyHeatmap"] = [];
556
+ var heatmapGrid = new Map<string, number>();
557
+ for (var hmi = 0; hmi < filtered.length; hmi++) {
558
+ var hmSess = filtered[hmi];
559
+ if (hmSess.startTime <= 0) continue;
560
+ var hmDate = new Date(hmSess.startTime);
561
+ var hmDay = hmDate.getDay();
562
+ var hmHour = hmDate.getHours();
563
+ var hmKey = hmDay + ":" + hmHour;
564
+ heatmapGrid.set(hmKey, (heatmapGrid.get(hmKey) || 0) + 1);
565
+ }
566
+ for (var hd = 0; hd < 7; hd++) {
567
+ for (var hh = 0; hh < 24; hh++) {
568
+ var hhKey = hd + ":" + hh;
569
+ hourlyHeatmap.push({ day: hd, hour: hh, count: heatmapGrid.get(hhKey) || 0 });
570
+ }
571
+ }
572
+
573
+ var sessionTimeline: AnalyticsPayload["sessionTimeline"] = [];
574
+ var tlSorted = filtered
575
+ .filter(function (s) { return s.startTime > 0 && s.endTime > 0; })
576
+ .sort(function (a, b) { return b.startTime - a.startTime; });
577
+ var tlCap = Math.min(tlSorted.length, 50);
578
+ for (var tli = 0; tli < tlCap; tli++) {
579
+ var tlSess = tlSorted[tli];
580
+ sessionTimeline.push({
581
+ id: tlSess.id,
582
+ title: tlSess.title,
583
+ project: tlSess.project,
584
+ start: tlSess.startTime,
585
+ end: tlSess.endTime,
586
+ cost: tlSess.cost,
587
+ });
588
+ }
589
+
590
+ var dailySummaryMap = new Map<string, { sessions: number; cost: number; tokens: number; tools: Map<string, number>; models: Map<string, number> }>();
591
+ for (var dsi = 0; dsi < filtered.length; dsi++) {
592
+ var dsSess = filtered[dsi];
593
+ var dsDate = formatDate(dsSess.endTime > 0 ? dsSess.endTime : dsSess.startTime);
594
+ var dsEntry = dailySummaryMap.get(dsDate);
595
+ if (!dsEntry) {
596
+ dsEntry = { sessions: 0, cost: 0, tokens: 0, tools: new Map(), models: new Map() };
597
+ dailySummaryMap.set(dsDate, dsEntry);
598
+ }
599
+ dsEntry.sessions++;
600
+ dsEntry.cost += dsSess.cost;
601
+ dsEntry.tokens += dsSess.inputTokens + dsSess.outputTokens;
602
+ dsSess.tools.forEach(function (count, tool) {
603
+ dsEntry!.tools.set(tool, (dsEntry!.tools.get(tool) || 0) + count);
604
+ });
605
+ dsSess.models.forEach(function (val, key) {
606
+ dsEntry!.models.set(key, (dsEntry!.models.get(key) || 0) + val.cost);
607
+ });
608
+ }
609
+
610
+ var dailySummaries: AnalyticsPayload["dailySummaries"] = [];
611
+ var dsSortedDates = Array.from(dailySummaryMap.keys()).sort();
612
+ for (var dsdi = 0; dsdi < dsSortedDates.length; dsdi++) {
613
+ var dsd = dsSortedDates[dsdi];
614
+ var dsData = dailySummaryMap.get(dsd)!;
615
+ var topTool = "";
616
+ var topToolCount = 0;
617
+ dsData.tools.forEach(function (count, tool) {
618
+ if (count > topToolCount) {
619
+ topToolCount = count;
620
+ topTool = tool;
621
+ }
622
+ });
623
+ var modelMix: Record<string, number> = {};
624
+ var modelTotal = 0;
625
+ dsData.models.forEach(function (cost) { modelTotal += cost; });
626
+ if (modelTotal > 0) {
627
+ dsData.models.forEach(function (cost, model) {
628
+ modelMix[model] = Math.round((cost / modelTotal) * 100) / 100;
629
+ });
630
+ }
631
+ dailySummaries.push({
632
+ date: dsd,
633
+ sessions: dsData.sessions,
634
+ cost: dsData.cost,
635
+ tokens: dsData.tokens,
636
+ topTool: topTool,
637
+ modelMix: modelMix,
638
+ });
639
+ }
640
+
523
641
  return {
524
642
  totalCost: totalCost,
525
643
  totalSessions: filtered.length,
@@ -545,6 +663,10 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
545
663
  responseTimeData: responseTimeData,
546
664
  contextUtilization: contextUtilization,
547
665
  tokenFlowSankey: tokenFlowSankey,
666
+ activityCalendar: activityCalendar,
667
+ hourlyHeatmap: hourlyHeatmap,
668
+ sessionTimeline: sessionTimeline,
669
+ dailySummaries: dailySummaries,
548
670
  };
549
671
  }
550
672
 
@@ -22,6 +22,11 @@ export interface AnalyticsPayload {
22
22
  responseTimeData: Array<{ tokens: number; duration: number; model: string; sessionId: string }>;
23
23
  contextUtilization: Array<{ messageIndex: number; contextPercent: number; sessionId: string; title: string }>;
24
24
  tokenFlowSankey: { nodes: Array<{ name: string }>; links: Array<{ source: number; target: number; value: number }> };
25
+
26
+ activityCalendar: Array<{ date: string; count: number; tokens: number; cost: number }>;
27
+ hourlyHeatmap: Array<{ day: number; hour: number; count: number }>;
28
+ sessionTimeline: Array<{ id: string; title: string; project: string; start: number; end: number; cost: number }>;
29
+ dailySummaries: Array<{ date: string; sessions: number; cost: number; tokens: number; topTool: string; modelMix: Record<string, number> }>;
25
30
  }
26
31
 
27
32
  export type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d" | "all";