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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-wrapped",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Your year in AI — a Spotify Wrapped-style dashboard for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 UTC consistently for day and hour buckets", () => {
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-21T00:30:00+09:00",
76
- parsedAt: "2026-02-21T00:45:00+09:00",
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["15"]?.sessions).toBe(1);
83
- expect(entry?.byHourSource["15"]?.codex?.sessions).toBe(1);
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
  });
@@ -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 toDayKey = (session: Session): string => {
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.toISOString().slice(0, 10);
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().toISOString().slice(0, 10);
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
- return String(parsed.getUTCHours()).padStart(2, "0");
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 const aggregateSessionsByDate = (sessions: Session[]): DailyStore => {
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 { aggregateSessionsByDate, mergeDailyAggregates } from "./aggregator";
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 { readDailyStore, readScanState, writeDailyStore, writeScanState } from "./store";
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(existingDaily, aggregateSessionsByDate(sessions));
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 {
@@ -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
  };