@litemetrics/node 0.2.0 → 0.3.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/dist/index.cjs CHANGED
@@ -42,6 +42,36 @@ var import_client = require("@clickhouse/client");
42
42
 
43
43
  // src/adapters/utils.ts
44
44
  var import_crypto = require("crypto");
45
+ function getTimezoneOffsetMs(date, timezone) {
46
+ const formatter = new Intl.DateTimeFormat("en-US", {
47
+ timeZone: timezone,
48
+ year: "numeric",
49
+ month: "2-digit",
50
+ day: "2-digit",
51
+ hour: "2-digit",
52
+ minute: "2-digit",
53
+ second: "2-digit",
54
+ hour12: false
55
+ });
56
+ const parts = formatter.formatToParts(date);
57
+ const get = (type) => parts.find((p) => p.type === type).value;
58
+ const y = parseInt(get("year"));
59
+ const m = parseInt(get("month")) - 1;
60
+ const d = parseInt(get("day"));
61
+ const h = parseInt(get("hour") === "24" ? "0" : get("hour"));
62
+ const mi = parseInt(get("minute"));
63
+ const s = parseInt(get("second"));
64
+ return Date.UTC(y, m, d, h, mi, s) - date.getTime();
65
+ }
66
+ function toUTCDate(value) {
67
+ if (value instanceof Date) return value;
68
+ if (typeof value === "number") return new Date(value);
69
+ const s = String(value).trim();
70
+ if (s.length >= 10 && !s.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(s)) {
71
+ return /* @__PURE__ */ new Date(s.replace(" ", "T") + "Z");
72
+ }
73
+ return new Date(s);
74
+ }
45
75
  function resolvePeriod(q) {
46
76
  const now = /* @__PURE__ */ new Date();
47
77
  const period = q.period ?? "7d";
@@ -108,60 +138,66 @@ function granularityToDateFormat(g) {
108
138
  return "%Y-%m";
109
139
  }
110
140
  }
111
- function fillBuckets(from, to, granularity, dateFormat, rows) {
141
+ function fillBuckets(from, to, granularity, dateFormat, rows, timezone) {
112
142
  const map = new Map(rows.map((r) => [r._id, r.value]));
113
143
  const points = [];
114
- const current = new Date(from);
144
+ const fromOffset = timezone ? getTimezoneOffsetMs(from, timezone) : 0;
145
+ const toOffset = timezone ? getTimezoneOffsetMs(to, timezone) : 0;
146
+ const current = new Date(from.getTime() + fromOffset);
147
+ const toWall = new Date(to.getTime() + toOffset);
115
148
  if (granularity === "hour") {
116
- current.setMinutes(0, 0, 0);
149
+ current.setUTCMinutes(0, 0, 0);
117
150
  } else if (granularity === "day") {
118
- current.setHours(0, 0, 0, 0);
151
+ current.setUTCHours(0, 0, 0, 0);
119
152
  } else if (granularity === "week") {
120
- const day = current.getDay();
153
+ const day = current.getUTCDay();
121
154
  const diff = day === 0 ? -6 : 1 - day;
122
- current.setDate(current.getDate() + diff);
123
- current.setHours(0, 0, 0, 0);
155
+ current.setUTCDate(current.getUTCDate() + diff);
156
+ current.setUTCHours(0, 0, 0, 0);
124
157
  } else if (granularity === "month") {
125
- current.setDate(1);
126
- current.setHours(0, 0, 0, 0);
158
+ current.setUTCDate(1);
159
+ current.setUTCHours(0, 0, 0, 0);
127
160
  }
128
- while (current <= to) {
161
+ while (current <= toWall) {
129
162
  const key = formatDateBucket(current, dateFormat);
130
- points.push({ date: current.toISOString(), value: map.get(key) ?? 0 });
163
+ const approxUtc = new Date(current.getTime() - fromOffset);
164
+ const exactOffset = timezone ? getTimezoneOffsetMs(approxUtc, timezone) : 0;
165
+ const realUtc = new Date(current.getTime() - exactOffset);
166
+ points.push({ date: realUtc.toISOString(), value: map.get(key) ?? 0 });
131
167
  if (granularity === "hour") {
132
- current.setHours(current.getHours() + 1);
168
+ current.setUTCHours(current.getUTCHours() + 1);
133
169
  } else if (granularity === "day") {
134
- current.setDate(current.getDate() + 1);
170
+ current.setUTCDate(current.getUTCDate() + 1);
135
171
  } else if (granularity === "week") {
136
- current.setDate(current.getDate() + 7);
172
+ current.setUTCDate(current.getUTCDate() + 7);
137
173
  } else if (granularity === "month") {
138
- current.setMonth(current.getMonth() + 1);
174
+ current.setUTCMonth(current.getUTCMonth() + 1);
139
175
  }
140
176
  }
141
177
  return points;
142
178
  }
143
179
  function formatDateBucket(date, format) {
144
- const y = date.getFullYear();
145
- const m = String(date.getMonth() + 1).padStart(2, "0");
146
- const d = String(date.getDate()).padStart(2, "0");
147
- const h = String(date.getHours()).padStart(2, "0");
180
+ const y = date.getUTCFullYear();
181
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
182
+ const d = String(date.getUTCDate()).padStart(2, "0");
183
+ const h = String(date.getUTCHours()).padStart(2, "0");
148
184
  if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
149
185
  if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
150
186
  if (format === "%Y-%m") return `${y}-${m}`;
151
187
  if (format === "%G-W%V") {
152
- const jan4 = new Date(y, 0, 4);
153
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
154
- const jan4Day = jan4.getDay() || 7;
188
+ const jan4 = new Date(Date.UTC(y, 0, 4));
189
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
190
+ const jan4Day = jan4.getUTCDay() || 7;
155
191
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
156
192
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
157
193
  }
158
194
  return date.toISOString();
159
195
  }
160
196
  function getISOWeek(date) {
161
- const y = date.getFullYear();
162
- const jan4 = new Date(y, 0, 4);
163
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
164
- const jan4Day = jan4.getDay() || 7;
197
+ const y = date.getUTCFullYear();
198
+ const jan4 = new Date(Date.UTC(y, 0, 4));
199
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
200
+ const jan4Day = jan4.getUTCDay() || 7;
165
201
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
166
202
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
167
203
  }
@@ -176,6 +212,38 @@ function generateSecretKey() {
176
212
  return `sk_${(0, import_crypto.randomBytes)(32).toString("hex")}`;
177
213
  }
178
214
 
215
+ // src/query-helpers.ts
216
+ function isValidTimezone(tz) {
217
+ try {
218
+ Intl.DateTimeFormat(void 0, { timeZone: tz });
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+ function extractQueryParams(req) {
225
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
226
+ let timezone;
227
+ if (typeof q.timezone === "string" && q.timezone) {
228
+ if (isValidTimezone(q.timezone)) {
229
+ timezone = q.timezone;
230
+ } else {
231
+ console.warn(`[litemetrics] Invalid timezone "${q.timezone}", falling back to UTC`);
232
+ }
233
+ }
234
+ return {
235
+ siteId: q.siteId,
236
+ metric: q.metric,
237
+ period: q.period,
238
+ dateFrom: q.dateFrom,
239
+ dateTo: q.dateTo,
240
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
241
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
242
+ compare: q.compare === "true" || q.compare === "1",
243
+ timezone
244
+ };
245
+ }
246
+
179
247
  // src/adapters/clickhouse.ts
180
248
  var EVENTS_TABLE = "litemetrics_events";
181
249
  var SITES_TABLE = "litemetrics_sites";
@@ -260,8 +328,15 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
260
328
  SETTINGS index_granularity = 8192
261
329
  `;
262
330
  function toCHDateTime(d) {
263
- const iso = typeof d === "string" ? d : d.toISOString();
264
- return iso.replace("T", " ").replace("Z", "");
331
+ const date = d instanceof Date ? d : toUTCDate(d);
332
+ const y = date.getUTCFullYear();
333
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
334
+ const da = String(date.getUTCDate()).padStart(2, "0");
335
+ const h = String(date.getUTCHours()).padStart(2, "0");
336
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
337
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
338
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
339
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}.${ms}`;
265
340
  }
266
341
  function normalizedUtmSourceExpr() {
267
342
  return `multiIf(
@@ -981,7 +1056,7 @@ var ClickHouseAdapter = class {
981
1056
  dateTo: params.dateTo
982
1057
  });
983
1058
  const granularity = params.granularity ?? autoGranularity(period);
984
- const bucketFn = this.granularityToClickHouseFunc(granularity);
1059
+ const bucketFn = this.granularityToClickHouseFunc(granularity, params.timezone);
985
1060
  const dateFormat = granularityToDateFormat(granularity);
986
1061
  const filter = buildFilterConditions(params.filters);
987
1062
  const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
@@ -1030,38 +1105,41 @@ var ClickHouseAdapter = class {
1030
1105
  new Date(dateRange.to),
1031
1106
  granularity,
1032
1107
  dateFormat,
1033
- mappedRows
1108
+ mappedRows,
1109
+ params.timezone
1034
1110
  );
1035
1111
  return { metric: params.metric, granularity, data };
1036
1112
  }
1037
- granularityToClickHouseFunc(g) {
1113
+ granularityToClickHouseFunc(g, timezone) {
1114
+ const safeTz = timezone && isValidTimezone(timezone) ? timezone : void 0;
1115
+ const tz = safeTz ? `, '${safeTz}'` : "";
1038
1116
  switch (g) {
1039
1117
  case "hour":
1040
- return "toStartOfHour(timestamp)";
1118
+ return `toStartOfHour(timestamp${tz})`;
1041
1119
  case "day":
1042
- return "toStartOfDay(timestamp)";
1120
+ return `toStartOfDay(timestamp${tz})`;
1043
1121
  case "week":
1044
- return "toStartOfWeek(timestamp, 1)";
1122
+ return `toStartOfWeek(timestamp, 1${tz})`;
1045
1123
  // 1 = Monday
1046
1124
  case "month":
1047
- return "toStartOfMonth(timestamp)";
1125
+ return `toStartOfMonth(timestamp${tz})`;
1048
1126
  }
1049
1127
  }
1050
1128
  convertClickHouseBucket(bucket, granularity) {
1051
- const date = new Date(bucket);
1052
- const y = date.getFullYear();
1053
- const m = String(date.getMonth() + 1).padStart(2, "0");
1054
- const d = String(date.getDate()).padStart(2, "0");
1055
- const h = String(date.getHours()).padStart(2, "0");
1129
+ const date = toUTCDate(bucket);
1130
+ const y = date.getUTCFullYear();
1131
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
1132
+ const d = String(date.getUTCDate()).padStart(2, "0");
1133
+ const h = String(date.getUTCHours()).padStart(2, "0");
1056
1134
  switch (granularity) {
1057
1135
  case "hour":
1058
1136
  return `${y}-${m}-${d}T${h}:00`;
1059
1137
  case "day":
1060
1138
  return `${y}-${m}-${d}`;
1061
1139
  case "week": {
1062
- const jan4 = new Date(y, 0, 4);
1063
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
1064
- const jan4Day = jan4.getDay() || 7;
1140
+ const jan4 = new Date(Date.UTC(y, 0, 4));
1141
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
1142
+ const jan4Day = jan4.getUTCDay() || 7;
1065
1143
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
1066
1144
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
1067
1145
  }
@@ -1090,7 +1168,7 @@ var ClickHouseAdapter = class {
1090
1168
  );
1091
1169
  const cohortMap = /* @__PURE__ */ new Map();
1092
1170
  for (const v of rows) {
1093
- const firstDate = new Date(v.first_event);
1171
+ const firstDate = toUTCDate(v.first_event);
1094
1172
  const cohortWeek = getISOWeek(firstDate);
1095
1173
  if (!cohortMap.has(cohortWeek)) {
1096
1174
  cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
@@ -1098,7 +1176,7 @@ var ClickHouseAdapter = class {
1098
1176
  const cohort = cohortMap.get(cohortWeek);
1099
1177
  cohort.visitors.add(v.visitor_id);
1100
1178
  const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
1101
- const d = new Date(w);
1179
+ const d = toUTCDate(w);
1102
1180
  return getISOWeek(d);
1103
1181
  });
1104
1182
  for (const w of eventWeeks) {
@@ -1265,8 +1343,8 @@ var ClickHouseAdapter = class {
1265
1343
  visitorId: String(u.visitor_id),
1266
1344
  userId: u.userId ? String(u.userId) : void 0,
1267
1345
  traits: this.parseJSON(u.traits),
1268
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1269
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1346
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1347
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1270
1348
  totalEvents: Number(u.totalEvents),
1271
1349
  totalPageviews: Number(u.totalPageviews),
1272
1350
  totalSessions: Number(u.totalSessions),
@@ -1389,8 +1467,8 @@ var ClickHouseAdapter = class {
1389
1467
  visitorIds,
1390
1468
  userId,
1391
1469
  traits: this.parseJSON(u.traits),
1392
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1393
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1470
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1471
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1394
1472
  totalEvents: Number(u.totalEvents),
1395
1473
  totalPageviews: Number(u.totalPageviews),
1396
1474
  totalSessions: Number(u.totalSessions),
@@ -1664,15 +1742,15 @@ var ClickHouseAdapter = class {
1664
1742
  domain: row.domain ? String(row.domain) : void 0,
1665
1743
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1666
1744
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
1667
- createdAt: new Date(String(row.created_at)).toISOString(),
1668
- updatedAt: new Date(String(row.updated_at)).toISOString()
1745
+ createdAt: toUTCDate(String(row.created_at)).toISOString(),
1746
+ updatedAt: toUTCDate(String(row.updated_at)).toISOString()
1669
1747
  };
1670
1748
  }
1671
1749
  toEventListItem(row) {
1672
1750
  return {
1673
1751
  id: String(row.event_id ?? ""),
1674
1752
  type: String(row.type),
1675
- timestamp: new Date(String(row.timestamp)).toISOString(),
1753
+ timestamp: toUTCDate(String(row.timestamp)).toISOString(),
1676
1754
  visitorId: String(row.visitor_id),
1677
1755
  sessionId: String(row.session_id),
1678
1756
  url: row.url ? String(row.url) : void 0,
@@ -3328,7 +3406,8 @@ async function createCollector(config) {
3328
3406
  dateFrom: params.dateFrom,
3329
3407
  dateTo: params.dateTo,
3330
3408
  granularity: q.granularity,
3331
- filters: q.filters ? JSON.parse(q.filters) : void 0
3409
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
3410
+ timezone: params.timezone
3332
3411
  };
3333
3412
  if (tsParams.metric === "conversions") {
3334
3413
  const site = await db.getSite(params.siteId);
@@ -3630,19 +3709,6 @@ async function parseBody(req) {
3630
3709
  req.on("error", reject);
3631
3710
  });
3632
3711
  }
3633
- function extractQueryParams(req) {
3634
- const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
3635
- return {
3636
- siteId: q.siteId,
3637
- metric: q.metric,
3638
- period: q.period,
3639
- dateFrom: q.dateFrom,
3640
- dateTo: q.dateTo,
3641
- limit: q.limit ? parseInt(q.limit, 10) : void 0,
3642
- filters: q.filters ? JSON.parse(q.filters) : void 0,
3643
- compare: q.compare === "true" || q.compare === "1"
3644
- };
3645
- }
3646
3712
  function sendJson(res, status, body) {
3647
3713
  if (typeof res.status === "function" && typeof res.json === "function") {
3648
3714
  res.status(status).json(body);