@litemetrics/node 0.2.0 → 0.3.1

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.js CHANGED
@@ -3,6 +3,36 @@ import { createClient } from "@clickhouse/client";
3
3
 
4
4
  // src/adapters/utils.ts
5
5
  import { randomBytes } from "crypto";
6
+ function getTimezoneOffsetMs(date, timezone) {
7
+ const formatter = new Intl.DateTimeFormat("en-US", {
8
+ timeZone: timezone,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ second: "2-digit",
15
+ hour12: false
16
+ });
17
+ const parts = formatter.formatToParts(date);
18
+ const get = (type) => parts.find((p) => p.type === type).value;
19
+ const y = parseInt(get("year"));
20
+ const m = parseInt(get("month")) - 1;
21
+ const d = parseInt(get("day"));
22
+ const h = parseInt(get("hour") === "24" ? "0" : get("hour"));
23
+ const mi = parseInt(get("minute"));
24
+ const s = parseInt(get("second"));
25
+ return Date.UTC(y, m, d, h, mi, s) - date.getTime();
26
+ }
27
+ function toUTCDate(value) {
28
+ if (value instanceof Date) return value;
29
+ if (typeof value === "number") return new Date(value);
30
+ const s = String(value).trim();
31
+ if (s.length >= 10 && !s.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(s)) {
32
+ return /* @__PURE__ */ new Date(s.replace(" ", "T") + "Z");
33
+ }
34
+ return new Date(s);
35
+ }
6
36
  function resolvePeriod(q) {
7
37
  const now = /* @__PURE__ */ new Date();
8
38
  const period = q.period ?? "7d";
@@ -69,60 +99,66 @@ function granularityToDateFormat(g) {
69
99
  return "%Y-%m";
70
100
  }
71
101
  }
72
- function fillBuckets(from, to, granularity, dateFormat, rows) {
102
+ function fillBuckets(from, to, granularity, dateFormat, rows, timezone) {
73
103
  const map = new Map(rows.map((r) => [r._id, r.value]));
74
104
  const points = [];
75
- const current = new Date(from);
105
+ const fromOffset = timezone ? getTimezoneOffsetMs(from, timezone) : 0;
106
+ const toOffset = timezone ? getTimezoneOffsetMs(to, timezone) : 0;
107
+ const current = new Date(from.getTime() + fromOffset);
108
+ const toWall = new Date(to.getTime() + toOffset);
76
109
  if (granularity === "hour") {
77
- current.setMinutes(0, 0, 0);
110
+ current.setUTCMinutes(0, 0, 0);
78
111
  } else if (granularity === "day") {
79
- current.setHours(0, 0, 0, 0);
112
+ current.setUTCHours(0, 0, 0, 0);
80
113
  } else if (granularity === "week") {
81
- const day = current.getDay();
114
+ const day = current.getUTCDay();
82
115
  const diff = day === 0 ? -6 : 1 - day;
83
- current.setDate(current.getDate() + diff);
84
- current.setHours(0, 0, 0, 0);
116
+ current.setUTCDate(current.getUTCDate() + diff);
117
+ current.setUTCHours(0, 0, 0, 0);
85
118
  } else if (granularity === "month") {
86
- current.setDate(1);
87
- current.setHours(0, 0, 0, 0);
119
+ current.setUTCDate(1);
120
+ current.setUTCHours(0, 0, 0, 0);
88
121
  }
89
- while (current <= to) {
122
+ while (current <= toWall) {
90
123
  const key = formatDateBucket(current, dateFormat);
91
- points.push({ date: current.toISOString(), value: map.get(key) ?? 0 });
124
+ const approxUtc = new Date(current.getTime() - fromOffset);
125
+ const exactOffset = timezone ? getTimezoneOffsetMs(approxUtc, timezone) : 0;
126
+ const realUtc = new Date(current.getTime() - exactOffset);
127
+ points.push({ date: realUtc.toISOString(), value: map.get(key) ?? 0 });
92
128
  if (granularity === "hour") {
93
- current.setHours(current.getHours() + 1);
129
+ current.setUTCHours(current.getUTCHours() + 1);
94
130
  } else if (granularity === "day") {
95
- current.setDate(current.getDate() + 1);
131
+ current.setUTCDate(current.getUTCDate() + 1);
96
132
  } else if (granularity === "week") {
97
- current.setDate(current.getDate() + 7);
133
+ current.setUTCDate(current.getUTCDate() + 7);
98
134
  } else if (granularity === "month") {
99
- current.setMonth(current.getMonth() + 1);
135
+ current.setUTCMonth(current.getUTCMonth() + 1);
100
136
  }
101
137
  }
102
138
  return points;
103
139
  }
104
140
  function formatDateBucket(date, format) {
105
- const y = date.getFullYear();
106
- const m = String(date.getMonth() + 1).padStart(2, "0");
107
- const d = String(date.getDate()).padStart(2, "0");
108
- const h = String(date.getHours()).padStart(2, "0");
141
+ const y = date.getUTCFullYear();
142
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
143
+ const d = String(date.getUTCDate()).padStart(2, "0");
144
+ const h = String(date.getUTCHours()).padStart(2, "0");
109
145
  if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
110
146
  if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
111
147
  if (format === "%Y-%m") return `${y}-${m}`;
112
148
  if (format === "%G-W%V") {
113
- const jan4 = new Date(y, 0, 4);
114
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
115
- const jan4Day = jan4.getDay() || 7;
149
+ const jan4 = new Date(Date.UTC(y, 0, 4));
150
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
151
+ const jan4Day = jan4.getUTCDay() || 7;
116
152
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
117
153
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
118
154
  }
119
155
  return date.toISOString();
120
156
  }
121
157
  function getISOWeek(date) {
122
- const y = date.getFullYear();
123
- const jan4 = new Date(y, 0, 4);
124
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
125
- const jan4Day = jan4.getDay() || 7;
158
+ const y = date.getUTCFullYear();
159
+ const jan4 = new Date(Date.UTC(y, 0, 4));
160
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
161
+ const jan4Day = jan4.getUTCDay() || 7;
126
162
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
127
163
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
128
164
  }
@@ -137,6 +173,38 @@ function generateSecretKey() {
137
173
  return `sk_${randomBytes(32).toString("hex")}`;
138
174
  }
139
175
 
176
+ // src/query-helpers.ts
177
+ function isValidTimezone(tz) {
178
+ try {
179
+ Intl.DateTimeFormat(void 0, { timeZone: tz });
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ function extractQueryParams(req) {
186
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
187
+ let timezone;
188
+ if (typeof q.timezone === "string" && q.timezone) {
189
+ if (isValidTimezone(q.timezone)) {
190
+ timezone = q.timezone;
191
+ } else {
192
+ console.warn(`[litemetrics] Invalid timezone "${q.timezone}", falling back to UTC`);
193
+ }
194
+ }
195
+ return {
196
+ siteId: q.siteId,
197
+ metric: q.metric,
198
+ period: q.period,
199
+ dateFrom: q.dateFrom,
200
+ dateTo: q.dateTo,
201
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
202
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
203
+ compare: q.compare === "true" || q.compare === "1",
204
+ timezone
205
+ };
206
+ }
207
+
140
208
  // src/adapters/clickhouse.ts
141
209
  var EVENTS_TABLE = "litemetrics_events";
142
210
  var SITES_TABLE = "litemetrics_sites";
@@ -221,8 +289,15 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
221
289
  SETTINGS index_granularity = 8192
222
290
  `;
223
291
  function toCHDateTime(d) {
224
- const iso = typeof d === "string" ? d : d.toISOString();
225
- return iso.replace("T", " ").replace("Z", "");
292
+ const date = d instanceof Date ? d : toUTCDate(d);
293
+ const y = date.getUTCFullYear();
294
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
295
+ const da = String(date.getUTCDate()).padStart(2, "0");
296
+ const h = String(date.getUTCHours()).padStart(2, "0");
297
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
298
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
299
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
300
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}.${ms}`;
226
301
  }
227
302
  function normalizedUtmSourceExpr() {
228
303
  return `multiIf(
@@ -942,7 +1017,7 @@ var ClickHouseAdapter = class {
942
1017
  dateTo: params.dateTo
943
1018
  });
944
1019
  const granularity = params.granularity ?? autoGranularity(period);
945
- const bucketFn = this.granularityToClickHouseFunc(granularity);
1020
+ const bucketFn = this.granularityToClickHouseFunc(granularity, params.timezone);
946
1021
  const dateFormat = granularityToDateFormat(granularity);
947
1022
  const filter = buildFilterConditions(params.filters);
948
1023
  const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
@@ -991,38 +1066,41 @@ var ClickHouseAdapter = class {
991
1066
  new Date(dateRange.to),
992
1067
  granularity,
993
1068
  dateFormat,
994
- mappedRows
1069
+ mappedRows,
1070
+ params.timezone
995
1071
  );
996
1072
  return { metric: params.metric, granularity, data };
997
1073
  }
998
- granularityToClickHouseFunc(g) {
1074
+ granularityToClickHouseFunc(g, timezone) {
1075
+ const safeTz = timezone && isValidTimezone(timezone) ? timezone : void 0;
1076
+ const tz = safeTz ? `, '${safeTz}'` : "";
999
1077
  switch (g) {
1000
1078
  case "hour":
1001
- return "toStartOfHour(timestamp)";
1079
+ return `toStartOfHour(timestamp${tz})`;
1002
1080
  case "day":
1003
- return "toStartOfDay(timestamp)";
1081
+ return `toStartOfDay(timestamp${tz})`;
1004
1082
  case "week":
1005
- return "toStartOfWeek(timestamp, 1)";
1083
+ return `toStartOfWeek(timestamp, 1${tz})`;
1006
1084
  // 1 = Monday
1007
1085
  case "month":
1008
- return "toStartOfMonth(timestamp)";
1086
+ return `toStartOfMonth(timestamp${tz})`;
1009
1087
  }
1010
1088
  }
1011
1089
  convertClickHouseBucket(bucket, granularity) {
1012
- const date = new Date(bucket);
1013
- const y = date.getFullYear();
1014
- const m = String(date.getMonth() + 1).padStart(2, "0");
1015
- const d = String(date.getDate()).padStart(2, "0");
1016
- const h = String(date.getHours()).padStart(2, "0");
1090
+ const date = toUTCDate(bucket);
1091
+ const y = date.getUTCFullYear();
1092
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
1093
+ const d = String(date.getUTCDate()).padStart(2, "0");
1094
+ const h = String(date.getUTCHours()).padStart(2, "0");
1017
1095
  switch (granularity) {
1018
1096
  case "hour":
1019
1097
  return `${y}-${m}-${d}T${h}:00`;
1020
1098
  case "day":
1021
1099
  return `${y}-${m}-${d}`;
1022
1100
  case "week": {
1023
- const jan4 = new Date(y, 0, 4);
1024
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
1025
- const jan4Day = jan4.getDay() || 7;
1101
+ const jan4 = new Date(Date.UTC(y, 0, 4));
1102
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
1103
+ const jan4Day = jan4.getUTCDay() || 7;
1026
1104
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
1027
1105
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
1028
1106
  }
@@ -1051,7 +1129,7 @@ var ClickHouseAdapter = class {
1051
1129
  );
1052
1130
  const cohortMap = /* @__PURE__ */ new Map();
1053
1131
  for (const v of rows) {
1054
- const firstDate = new Date(v.first_event);
1132
+ const firstDate = toUTCDate(v.first_event);
1055
1133
  const cohortWeek = getISOWeek(firstDate);
1056
1134
  if (!cohortMap.has(cohortWeek)) {
1057
1135
  cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
@@ -1059,7 +1137,7 @@ var ClickHouseAdapter = class {
1059
1137
  const cohort = cohortMap.get(cohortWeek);
1060
1138
  cohort.visitors.add(v.visitor_id);
1061
1139
  const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
1062
- const d = new Date(w);
1140
+ const d = toUTCDate(w);
1063
1141
  return getISOWeek(d);
1064
1142
  });
1065
1143
  for (const w of eventWeeks) {
@@ -1226,8 +1304,8 @@ var ClickHouseAdapter = class {
1226
1304
  visitorId: String(u.visitor_id),
1227
1305
  userId: u.userId ? String(u.userId) : void 0,
1228
1306
  traits: this.parseJSON(u.traits),
1229
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1230
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1307
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1308
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1231
1309
  totalEvents: Number(u.totalEvents),
1232
1310
  totalPageviews: Number(u.totalPageviews),
1233
1311
  totalSessions: Number(u.totalSessions),
@@ -1350,8 +1428,8 @@ var ClickHouseAdapter = class {
1350
1428
  visitorIds,
1351
1429
  userId,
1352
1430
  traits: this.parseJSON(u.traits),
1353
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1354
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1431
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1432
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1355
1433
  totalEvents: Number(u.totalEvents),
1356
1434
  totalPageviews: Number(u.totalPageviews),
1357
1435
  totalSessions: Number(u.totalSessions),
@@ -1625,15 +1703,15 @@ var ClickHouseAdapter = class {
1625
1703
  domain: row.domain ? String(row.domain) : void 0,
1626
1704
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1627
1705
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
1628
- createdAt: new Date(String(row.created_at)).toISOString(),
1629
- updatedAt: new Date(String(row.updated_at)).toISOString()
1706
+ createdAt: toUTCDate(String(row.created_at)).toISOString(),
1707
+ updatedAt: toUTCDate(String(row.updated_at)).toISOString()
1630
1708
  };
1631
1709
  }
1632
1710
  toEventListItem(row) {
1633
1711
  return {
1634
1712
  id: String(row.event_id ?? ""),
1635
1713
  type: String(row.type),
1636
- timestamp: new Date(String(row.timestamp)).toISOString(),
1714
+ timestamp: toUTCDate(String(row.timestamp)).toISOString(),
1637
1715
  visitorId: String(row.visitor_id),
1638
1716
  sessionId: String(row.session_id),
1639
1717
  url: row.url ? String(row.url) : void 0,
@@ -3289,7 +3367,8 @@ async function createCollector(config) {
3289
3367
  dateFrom: params.dateFrom,
3290
3368
  dateTo: params.dateTo,
3291
3369
  granularity: q.granularity,
3292
- filters: q.filters ? JSON.parse(q.filters) : void 0
3370
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
3371
+ timezone: params.timezone
3293
3372
  };
3294
3373
  if (tsParams.metric === "conversions") {
3295
3374
  const site = await db.getSite(params.siteId);
@@ -3591,19 +3670,6 @@ async function parseBody(req) {
3591
3670
  req.on("error", reject);
3592
3671
  });
3593
3672
  }
3594
- function extractQueryParams(req) {
3595
- const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
3596
- return {
3597
- siteId: q.siteId,
3598
- metric: q.metric,
3599
- period: q.period,
3600
- dateFrom: q.dateFrom,
3601
- dateTo: q.dateTo,
3602
- limit: q.limit ? parseInt(q.limit, 10) : void 0,
3603
- filters: q.filters ? JSON.parse(q.filters) : void 0,
3604
- compare: q.compare === "true" || q.compare === "1"
3605
- };
3606
- }
3607
3673
  function sendJson(res, status, body) {
3608
3674
  if (typeof res.status === "function" && typeof res.json === "function") {
3609
3675
  res.status(status).json(body);