ai-wrapped 1.3.3 → 1.3.4
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/package.json +1 -1
- package/src/bun/aggregator.test.ts +21 -8
- package/src/bun/aggregator.ts +75 -8
- package/src/bun/scan.ts +21 -4
- package/src/bun/store.test.ts +41 -1
- package/src/bun/store.ts +92 -0
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ describe("aggregateSessionsByDate", () => {
|
|
|
34
34
|
makeSession({ id: "s2", totalCostUsd: 0.75 }),
|
|
35
35
|
makeSession({ id: "s3", repoName: "other-repo", totalCostUsd: 2.0 }),
|
|
36
36
|
makeSession({ id: "s4", repoName: null, totalCostUsd: 3.0 }),
|
|
37
|
-
]);
|
|
37
|
+
], { timeZone: "UTC" });
|
|
38
38
|
|
|
39
39
|
const entry = daily["2026-02-21"];
|
|
40
40
|
expect(entry).toBeDefined();
|
|
@@ -59,7 +59,7 @@ describe("aggregateSessionsByDate", () => {
|
|
|
59
59
|
startTime: null,
|
|
60
60
|
parsedAt: "2026-02-21T13:40:00.000Z",
|
|
61
61
|
}),
|
|
62
|
-
]);
|
|
62
|
+
], { timeZone: "UTC" });
|
|
63
63
|
|
|
64
64
|
const entry = daily["2026-02-21"];
|
|
65
65
|
expect(entry).toBeDefined();
|
|
@@ -68,20 +68,33 @@ describe("aggregateSessionsByDate", () => {
|
|
|
68
68
|
expect(entry?.totals.sessions).toBe(2);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("uses
|
|
71
|
+
test("uses requested timezone consistently for day and hour buckets", () => {
|
|
72
72
|
const daily = aggregateSessionsByDate([
|
|
73
73
|
makeSession({
|
|
74
74
|
id: "tz-1",
|
|
75
|
-
startTime: "2026-02-
|
|
76
|
-
parsedAt: "2026-02-
|
|
75
|
+
startTime: "2026-02-21T07:30:00.000Z",
|
|
76
|
+
parsedAt: "2026-02-21T07:45:00.000Z",
|
|
77
77
|
}),
|
|
78
|
-
]);
|
|
78
|
+
], { timeZone: "America/Los_Angeles" });
|
|
79
79
|
|
|
80
80
|
const entry = daily["2026-02-20"];
|
|
81
81
|
expect(entry).toBeDefined();
|
|
82
|
-
expect(entry?.byHour["
|
|
83
|
-
expect(entry?.byHourSource["
|
|
82
|
+
expect(entry?.byHour["23"]?.sessions).toBe(1);
|
|
83
|
+
expect(entry?.byHourSource["23"]?.codex?.sessions).toBe(1);
|
|
84
84
|
expect(entry?.totals.sessions).toBe(1);
|
|
85
85
|
expect(daily["2026-02-21"]).toBeUndefined();
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("falls back to UTC when timezone is invalid", () => {
|
|
89
|
+
const daily = aggregateSessionsByDate([
|
|
90
|
+
makeSession({
|
|
91
|
+
id: "tz-invalid",
|
|
92
|
+
startTime: "2026-02-21T10:10:00.000Z",
|
|
93
|
+
}),
|
|
94
|
+
], { timeZone: "Invalid/Timezone" });
|
|
95
|
+
|
|
96
|
+
const entry = daily["2026-02-21"];
|
|
97
|
+
expect(entry).toBeDefined();
|
|
98
|
+
expect(entry?.byHour["10"]?.sessions).toBe(1);
|
|
99
|
+
});
|
|
87
100
|
});
|
package/src/bun/aggregator.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
type DayStats,
|
|
7
7
|
} from "./store";
|
|
8
8
|
|
|
9
|
+
const DEFAULT_TIME_ZONE = "UTC";
|
|
10
|
+
|
|
9
11
|
const addStats = (target: DayStats, source: DayStats): void => {
|
|
10
12
|
target.sessions += source.sessions;
|
|
11
13
|
target.messages += source.messages;
|
|
@@ -33,10 +35,51 @@ const parseSessionTimestamp = (session: Session): Date | null => {
|
|
|
33
35
|
return parsed;
|
|
34
36
|
};
|
|
35
37
|
|
|
36
|
-
const
|
|
38
|
+
const resolveFormatterTimeZone = (timeZone?: string): string => {
|
|
39
|
+
const candidate =
|
|
40
|
+
typeof timeZone === "string" && timeZone.trim().length > 0
|
|
41
|
+
? timeZone.trim()
|
|
42
|
+
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
43
|
+
if (!candidate) {
|
|
44
|
+
return DEFAULT_TIME_ZONE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Validate the zone before use. Falls back to UTC for invalid identifiers.
|
|
49
|
+
void new Intl.DateTimeFormat("en-US", { timeZone: candidate });
|
|
50
|
+
return candidate;
|
|
51
|
+
} catch {
|
|
52
|
+
return DEFAULT_TIME_ZONE;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const formatDateKey = (date: Date, formatter: Intl.DateTimeFormat): string => {
|
|
57
|
+
const parts = formatter.formatToParts(date);
|
|
58
|
+
const year = parts.find((part) => part.type === "year")?.value;
|
|
59
|
+
const month = parts.find((part) => part.type === "month")?.value;
|
|
60
|
+
const day = parts.find((part) => part.type === "day")?.value;
|
|
61
|
+
|
|
62
|
+
if (!year || !month || !day) {
|
|
63
|
+
return date.toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${year}-${month}-${day}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const formatHourKey = (date: Date, formatter: Intl.DateTimeFormat): string | null => {
|
|
70
|
+
const hourValue = formatter.formatToParts(date).find((part) => part.type === "hour")?.value;
|
|
71
|
+
if (!hourValue) return null;
|
|
72
|
+
|
|
73
|
+
const hourNum = Number(hourValue);
|
|
74
|
+
if (!Number.isFinite(hourNum)) return null;
|
|
75
|
+
|
|
76
|
+
return String(hourNum).padStart(2, "0");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const toDayKey = (session: Session, dateFormatter: Intl.DateTimeFormat): string => {
|
|
37
80
|
const parsed = parseSessionTimestamp(session);
|
|
38
81
|
if (parsed) {
|
|
39
|
-
return parsed
|
|
82
|
+
return formatDateKey(parsed, dateFormatter);
|
|
40
83
|
}
|
|
41
84
|
|
|
42
85
|
const fallback = session.startTime ?? session.parsedAt;
|
|
@@ -44,13 +87,14 @@ const toDayKey = (session: Session): string => {
|
|
|
44
87
|
return fallback.slice(0, 10);
|
|
45
88
|
}
|
|
46
89
|
|
|
47
|
-
return new Date()
|
|
90
|
+
return formatDateKey(new Date(), dateFormatter);
|
|
48
91
|
};
|
|
49
92
|
|
|
50
|
-
const toHourKey = (session: Session): string | null => {
|
|
93
|
+
const toHourKey = (session: Session, hourFormatter: Intl.DateTimeFormat): string | null => {
|
|
51
94
|
const parsed = parseSessionTimestamp(session);
|
|
52
95
|
if (!parsed) return null;
|
|
53
|
-
|
|
96
|
+
|
|
97
|
+
return formatHourKey(parsed, hourFormatter);
|
|
54
98
|
};
|
|
55
99
|
|
|
56
100
|
const toSessionStats = (session: Session): DayStats => ({
|
|
@@ -123,11 +167,34 @@ const sortDailyStore = (daily: DailyStore): DailyStore => {
|
|
|
123
167
|
return output;
|
|
124
168
|
};
|
|
125
169
|
|
|
126
|
-
export
|
|
170
|
+
export interface AggregateSessionsOptions {
|
|
171
|
+
timeZone?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const resolveAggregationTimeZone = (timeZone?: string): string =>
|
|
175
|
+
resolveFormatterTimeZone(timeZone);
|
|
176
|
+
|
|
177
|
+
export const aggregateSessionsByDate = (
|
|
178
|
+
sessions: Session[],
|
|
179
|
+
options: AggregateSessionsOptions = {},
|
|
180
|
+
): DailyStore => {
|
|
181
|
+
const timeZone = resolveFormatterTimeZone(options.timeZone);
|
|
182
|
+
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
|
183
|
+
timeZone,
|
|
184
|
+
year: "numeric",
|
|
185
|
+
month: "2-digit",
|
|
186
|
+
day: "2-digit",
|
|
187
|
+
});
|
|
188
|
+
const hourFormatter = new Intl.DateTimeFormat("en-US", {
|
|
189
|
+
timeZone,
|
|
190
|
+
hour: "2-digit",
|
|
191
|
+
hourCycle: "h23",
|
|
192
|
+
});
|
|
193
|
+
|
|
127
194
|
const daily: DailyStore = {};
|
|
128
195
|
|
|
129
196
|
for (const session of sessions) {
|
|
130
|
-
const date = toDayKey(session);
|
|
197
|
+
const date = toDayKey(session, dateFormatter);
|
|
131
198
|
const entry = ensureDateEntry(daily, date);
|
|
132
199
|
const modelKey = session.model && session.model.trim().length > 0 ? session.model : "unknown";
|
|
133
200
|
const repoKey = toRepoKey(session.repoName);
|
|
@@ -143,7 +210,7 @@ export const aggregateSessionsByDate = (sessions: Session[]): DailyStore => {
|
|
|
143
210
|
entry.byRepo[repoKey] = createEmptyDayStats();
|
|
144
211
|
}
|
|
145
212
|
|
|
146
|
-
const hourKey = toHourKey(session);
|
|
213
|
+
const hourKey = toHourKey(session, hourFormatter);
|
|
147
214
|
if (hourKey !== null) {
|
|
148
215
|
if (!entry.byHour[hourKey]) {
|
|
149
216
|
entry.byHour[hourKey] = createEmptyDayStats();
|
package/src/bun/scan.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { SessionSource } from "../shared/schema";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
aggregateSessionsByDate,
|
|
4
|
+
mergeDailyAggregates,
|
|
5
|
+
resolveAggregationTimeZone,
|
|
6
|
+
} from "./aggregator";
|
|
3
7
|
import { discoverAll } from "./discovery";
|
|
4
8
|
import { normalizeSession } from "./normalizer";
|
|
5
9
|
import { parseFile } from "./parsers";
|
|
6
10
|
import type { Session } from "./session-schema";
|
|
7
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
readDailyStore,
|
|
13
|
+
readScanState,
|
|
14
|
+
writeAggregationMeta,
|
|
15
|
+
writeDailyStore,
|
|
16
|
+
writeScanState,
|
|
17
|
+
} from "./store";
|
|
8
18
|
|
|
9
19
|
export interface ScanOptions {
|
|
10
20
|
fullScan?: boolean;
|
|
11
21
|
sources?: SessionSource[];
|
|
22
|
+
timeZone?: string;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
export interface ScanResult {
|
|
@@ -18,6 +29,7 @@ export interface ScanResult {
|
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export const runScan = async (options: ScanOptions = {}): Promise<ScanResult> => {
|
|
32
|
+
const aggregationTimeZone = resolveAggregationTimeZone(options.timeZone);
|
|
21
33
|
const candidates = await discoverAll(options.sources);
|
|
22
34
|
const scanState = await readScanState();
|
|
23
35
|
|
|
@@ -62,11 +74,16 @@ export const runScan = async (options: ScanOptions = {}): Promise<ScanResult> =>
|
|
|
62
74
|
await writeScanState(scanState);
|
|
63
75
|
|
|
64
76
|
if (options.fullScan) {
|
|
65
|
-
await writeDailyStore(aggregateSessionsByDate(sessions));
|
|
77
|
+
await writeDailyStore(aggregateSessionsByDate(sessions, { timeZone: aggregationTimeZone }));
|
|
78
|
+
await writeAggregationMeta(aggregationTimeZone);
|
|
66
79
|
} else if (sessions.length > 0) {
|
|
67
80
|
const existingDaily = await readDailyStore();
|
|
68
|
-
const nextDaily = mergeDailyAggregates(
|
|
81
|
+
const nextDaily = mergeDailyAggregates(
|
|
82
|
+
existingDaily,
|
|
83
|
+
aggregateSessionsByDate(sessions, { timeZone: aggregationTimeZone }),
|
|
84
|
+
);
|
|
69
85
|
await writeDailyStore(nextDaily);
|
|
86
|
+
await writeAggregationMeta(aggregationTimeZone);
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
return {
|
package/src/bun/store.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { rawDailyStoreMissingHourDimension } from "./store";
|
|
2
|
+
import { rawAggregationMetaNeedsTimeZoneBackfill, rawDailyStoreMissingHourDimension } from "./store";
|
|
3
3
|
|
|
4
4
|
describe("rawDailyStoreMissingHourDimension", () => {
|
|
5
5
|
test("returns false for non-record input", () => {
|
|
@@ -101,3 +101,43 @@ describe("rawDailyStoreMissingHourDimension", () => {
|
|
|
101
101
|
).toBe(false);
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("rawAggregationMetaNeedsTimeZoneBackfill", () => {
|
|
106
|
+
test("returns true when meta is missing or malformed", () => {
|
|
107
|
+
expect(rawAggregationMetaNeedsTimeZoneBackfill(null, "America/Los_Angeles")).toBe(true);
|
|
108
|
+
expect(rawAggregationMetaNeedsTimeZoneBackfill({}, "America/Los_Angeles")).toBe(true);
|
|
109
|
+
expect(
|
|
110
|
+
rawAggregationMetaNeedsTimeZoneBackfill(
|
|
111
|
+
{ version: "1", timeZone: "America/Los_Angeles" },
|
|
112
|
+
"America/Los_Angeles",
|
|
113
|
+
),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns true when version changes", () => {
|
|
118
|
+
expect(
|
|
119
|
+
rawAggregationMetaNeedsTimeZoneBackfill(
|
|
120
|
+
{ version: 2, timeZone: "America/Los_Angeles" },
|
|
121
|
+
"America/Los_Angeles",
|
|
122
|
+
),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("returns true when timezone changes", () => {
|
|
127
|
+
expect(
|
|
128
|
+
rawAggregationMetaNeedsTimeZoneBackfill(
|
|
129
|
+
{ version: 1, timeZone: "UTC" },
|
|
130
|
+
"America/Los_Angeles",
|
|
131
|
+
),
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns false for matching version and timezone", () => {
|
|
136
|
+
expect(
|
|
137
|
+
rawAggregationMetaNeedsTimeZoneBackfill(
|
|
138
|
+
{ version: 1, timeZone: "America/Los_Angeles" },
|
|
139
|
+
"America/Los_Angeles",
|
|
140
|
+
),
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/bun/store.ts
CHANGED
|
@@ -37,10 +37,17 @@ export interface DailyAggregateEntry {
|
|
|
37
37
|
|
|
38
38
|
export type DailyStore = Record<string, DailyAggregateEntry>;
|
|
39
39
|
|
|
40
|
+
export interface AggregationMeta {
|
|
41
|
+
version: number;
|
|
42
|
+
timeZone: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
const DATA_DIR = join(homedir(), ".ai-wrapped");
|
|
41
46
|
const SCAN_STATE_PATH = join(DATA_DIR, "scan-state.json");
|
|
42
47
|
const DAILY_PATH = join(DATA_DIR, "daily.json");
|
|
48
|
+
const AGGREGATION_META_PATH = join(DATA_DIR, "aggregation-meta.json");
|
|
43
49
|
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
|
|
50
|
+
const AGGREGATION_META_VERSION = 1;
|
|
44
51
|
|
|
45
52
|
const DEFAULT_SETTINGS: AppSettings = {
|
|
46
53
|
scanOnLaunch: true,
|
|
@@ -179,6 +186,29 @@ const normalizeDailyStore = (value: unknown): DailyStore => {
|
|
|
179
186
|
return output;
|
|
180
187
|
};
|
|
181
188
|
|
|
189
|
+
const normalizeAggregationMeta = (value: unknown): AggregationMeta | null => {
|
|
190
|
+
if (!isRecord(value)) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const version = value.version;
|
|
195
|
+
const timeZone = value.timeZone;
|
|
196
|
+
if (
|
|
197
|
+
typeof version !== "number" ||
|
|
198
|
+
!Number.isFinite(version) ||
|
|
199
|
+
!Number.isInteger(version) ||
|
|
200
|
+
typeof timeZone !== "string" ||
|
|
201
|
+
timeZone.trim().length === 0
|
|
202
|
+
) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
version,
|
|
208
|
+
timeZone: timeZone.trim(),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
182
212
|
const normalizeScanState = (value: unknown): ScanStateStore => {
|
|
183
213
|
if (!isRecord(value)) {
|
|
184
214
|
return {};
|
|
@@ -264,6 +294,26 @@ export const writeDailyStore = async (daily: DailyStore): Promise<void> => {
|
|
|
264
294
|
await writeJson(DAILY_PATH, dailyCache);
|
|
265
295
|
};
|
|
266
296
|
|
|
297
|
+
const rawDailyStoreHasSessions = (raw: unknown): boolean => {
|
|
298
|
+
if (!isRecord(raw)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const rawEntry of Object.values(raw)) {
|
|
303
|
+
if (!isRecord(rawEntry)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const totals = isRecord(rawEntry.totals) ? rawEntry.totals : null;
|
|
308
|
+
const hasSessions = totals && typeof totals.sessions === "number" && totals.sessions > 0;
|
|
309
|
+
if (hasSessions) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return false;
|
|
315
|
+
};
|
|
316
|
+
|
|
267
317
|
export const dailyStoreMissingRepoDimension = async (): Promise<boolean> => {
|
|
268
318
|
const raw = await readJson<unknown>(DAILY_PATH, {});
|
|
269
319
|
if (!isRecord(raw)) {
|
|
@@ -321,11 +371,52 @@ export const rawDailyStoreMissingHourDimension = (raw: unknown): boolean => {
|
|
|
321
371
|
return false;
|
|
322
372
|
};
|
|
323
373
|
|
|
374
|
+
export const rawAggregationMetaNeedsTimeZoneBackfill = (
|
|
375
|
+
rawMeta: unknown,
|
|
376
|
+
currentTimeZone: string,
|
|
377
|
+
): boolean => {
|
|
378
|
+
const normalizedCurrent =
|
|
379
|
+
typeof currentTimeZone === "string" && currentTimeZone.trim().length > 0
|
|
380
|
+
? currentTimeZone.trim()
|
|
381
|
+
: "UTC";
|
|
382
|
+
const meta = normalizeAggregationMeta(rawMeta);
|
|
383
|
+
if (!meta) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (meta.version !== AGGREGATION_META_VERSION) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return meta.timeZone !== normalizedCurrent;
|
|
392
|
+
};
|
|
393
|
+
|
|
324
394
|
export const dailyStoreMissingHourDimension = async (): Promise<boolean> => {
|
|
325
395
|
const raw = await readJson<unknown>(DAILY_PATH, {});
|
|
326
396
|
return rawDailyStoreMissingHourDimension(raw);
|
|
327
397
|
};
|
|
328
398
|
|
|
399
|
+
export const dailyStoreNeedsTimeZoneBackfill = async (
|
|
400
|
+
currentTimeZone: string,
|
|
401
|
+
): Promise<boolean> => {
|
|
402
|
+
const rawDaily = await readJson<unknown>(DAILY_PATH, {});
|
|
403
|
+
if (!rawDailyStoreHasSessions(rawDaily)) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const rawMeta = await readJson<unknown>(AGGREGATION_META_PATH, null);
|
|
408
|
+
return rawAggregationMetaNeedsTimeZoneBackfill(rawMeta, currentTimeZone);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
export const writeAggregationMeta = async (timeZone: string): Promise<void> => {
|
|
412
|
+
const normalizedTimeZone =
|
|
413
|
+
typeof timeZone === "string" && timeZone.trim().length > 0 ? timeZone.trim() : "UTC";
|
|
414
|
+
await writeJson<AggregationMeta>(AGGREGATION_META_PATH, {
|
|
415
|
+
version: AGGREGATION_META_VERSION,
|
|
416
|
+
timeZone: normalizedTimeZone,
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
|
|
329
420
|
export const getSettings = async (): Promise<AppSettings> => {
|
|
330
421
|
if (settingsCache === null) {
|
|
331
422
|
const raw = await readJson<unknown>(SETTINGS_PATH, DEFAULT_SETTINGS);
|
|
@@ -344,5 +435,6 @@ export const paths = {
|
|
|
344
435
|
dataDir: DATA_DIR,
|
|
345
436
|
scanState: SCAN_STATE_PATH,
|
|
346
437
|
daily: DAILY_PATH,
|
|
438
|
+
aggregationMeta: AGGREGATION_META_PATH,
|
|
347
439
|
settings: SETTINGS_PATH,
|
|
348
440
|
};
|