tacilytics 0.0.0-stage → 2.0.2

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.
Files changed (307) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -2
  4. package/cli.js +173 -0
  5. package/day-salt.js +55 -0
  6. package/package.json +56 -4
  7. package/public/app.js +788 -0
  8. package/public/assets/DISCLAIMER-simple-icons.md +51 -0
  9. package/public/assets/LICENSE-lucide.txt +43 -0
  10. package/public/assets/LICENSE-simple-icons.md +30 -0
  11. package/public/assets/cosmic-bg.webp +0 -0
  12. package/public/assets/flags/LICENSE-flag-icons.txt +21 -0
  13. package/public/assets/flags/ad.svg +150 -0
  14. package/public/assets/flags/ae.svg +6 -0
  15. package/public/assets/flags/af.svg +81 -0
  16. package/public/assets/flags/ag.svg +14 -0
  17. package/public/assets/flags/ai.svg +29 -0
  18. package/public/assets/flags/al.svg +5 -0
  19. package/public/assets/flags/am.svg +5 -0
  20. package/public/assets/flags/ao.svg +13 -0
  21. package/public/assets/flags/aq.svg +5 -0
  22. package/public/assets/flags/ar.svg +32 -0
  23. package/public/assets/flags/arab.svg +109 -0
  24. package/public/assets/flags/as.svg +72 -0
  25. package/public/assets/flags/asean.svg +13 -0
  26. package/public/assets/flags/at.svg +4 -0
  27. package/public/assets/flags/au.svg +8 -0
  28. package/public/assets/flags/aw.svg +186 -0
  29. package/public/assets/flags/ax.svg +18 -0
  30. package/public/assets/flags/az.svg +8 -0
  31. package/public/assets/flags/ba.svg +12 -0
  32. package/public/assets/flags/bb.svg +6 -0
  33. package/public/assets/flags/bd.svg +4 -0
  34. package/public/assets/flags/be.svg +7 -0
  35. package/public/assets/flags/bf.svg +7 -0
  36. package/public/assets/flags/bg.svg +5 -0
  37. package/public/assets/flags/bh.svg +4 -0
  38. package/public/assets/flags/bi.svg +15 -0
  39. package/public/assets/flags/bj.svg +14 -0
  40. package/public/assets/flags/bl.svg +5 -0
  41. package/public/assets/flags/bm.svg +97 -0
  42. package/public/assets/flags/bn.svg +36 -0
  43. package/public/assets/flags/bo.svg +673 -0
  44. package/public/assets/flags/bq.svg +5 -0
  45. package/public/assets/flags/br.svg +45 -0
  46. package/public/assets/flags/bs.svg +13 -0
  47. package/public/assets/flags/bt.svg +89 -0
  48. package/public/assets/flags/bv.svg +13 -0
  49. package/public/assets/flags/bw.svg +7 -0
  50. package/public/assets/flags/by.svg +18 -0
  51. package/public/assets/flags/bz.svg +145 -0
  52. package/public/assets/flags/ca.svg +4 -0
  53. package/public/assets/flags/cc.svg +19 -0
  54. package/public/assets/flags/cd.svg +5 -0
  55. package/public/assets/flags/cefta.svg +13 -0
  56. package/public/assets/flags/cf.svg +15 -0
  57. package/public/assets/flags/cg.svg +12 -0
  58. package/public/assets/flags/ch.svg +9 -0
  59. package/public/assets/flags/ci.svg +7 -0
  60. package/public/assets/flags/ck.svg +9 -0
  61. package/public/assets/flags/cl.svg +13 -0
  62. package/public/assets/flags/cm.svg +15 -0
  63. package/public/assets/flags/cn.svg +11 -0
  64. package/public/assets/flags/co.svg +7 -0
  65. package/public/assets/flags/cp.svg +7 -0
  66. package/public/assets/flags/cr.svg +7 -0
  67. package/public/assets/flags/cu.svg +13 -0
  68. package/public/assets/flags/cv.svg +13 -0
  69. package/public/assets/flags/cw.svg +14 -0
  70. package/public/assets/flags/cx.svg +15 -0
  71. package/public/assets/flags/cy.svg +6 -0
  72. package/public/assets/flags/cz.svg +5 -0
  73. package/public/assets/flags/de.svg +5 -0
  74. package/public/assets/flags/dg.svg +130 -0
  75. package/public/assets/flags/dj.svg +13 -0
  76. package/public/assets/flags/dk.svg +5 -0
  77. package/public/assets/flags/dm.svg +152 -0
  78. package/public/assets/flags/do.svg +121 -0
  79. package/public/assets/flags/dz.svg +5 -0
  80. package/public/assets/flags/eac.svg +48 -0
  81. package/public/assets/flags/ec.svg +138 -0
  82. package/public/assets/flags/ee.svg +5 -0
  83. package/public/assets/flags/eg.svg +38 -0
  84. package/public/assets/flags/eh.svg +16 -0
  85. package/public/assets/flags/er.svg +8 -0
  86. package/public/assets/flags/es-ct.svg +4 -0
  87. package/public/assets/flags/es-ga.svg +187 -0
  88. package/public/assets/flags/es-pv.svg +5 -0
  89. package/public/assets/flags/es.svg +544 -0
  90. package/public/assets/flags/et.svg +14 -0
  91. package/public/assets/flags/eu.svg +28 -0
  92. package/public/assets/flags/fi.svg +5 -0
  93. package/public/assets/flags/fj.svg +120 -0
  94. package/public/assets/flags/fk.svg +90 -0
  95. package/public/assets/flags/fm.svg +11 -0
  96. package/public/assets/flags/fo.svg +12 -0
  97. package/public/assets/flags/fr.svg +5 -0
  98. package/public/assets/flags/ga.svg +7 -0
  99. package/public/assets/flags/gb-eng.svg +5 -0
  100. package/public/assets/flags/gb-nir.svg +132 -0
  101. package/public/assets/flags/gb-sct.svg +4 -0
  102. package/public/assets/flags/gb-wls.svg +9 -0
  103. package/public/assets/flags/gb.svg +7 -0
  104. package/public/assets/flags/gd.svg +27 -0
  105. package/public/assets/flags/ge.svg +6 -0
  106. package/public/assets/flags/gf.svg +5 -0
  107. package/public/assets/flags/gg.svg +9 -0
  108. package/public/assets/flags/gh.svg +6 -0
  109. package/public/assets/flags/gi.svg +32 -0
  110. package/public/assets/flags/gl.svg +4 -0
  111. package/public/assets/flags/gm.svg +14 -0
  112. package/public/assets/flags/gn.svg +7 -0
  113. package/public/assets/flags/gp.svg +5 -0
  114. package/public/assets/flags/gq.svg +23 -0
  115. package/public/assets/flags/gr.svg +16 -0
  116. package/public/assets/flags/gs.svg +133 -0
  117. package/public/assets/flags/gt.svg +204 -0
  118. package/public/assets/flags/gu.svg +19 -0
  119. package/public/assets/flags/gw.svg +13 -0
  120. package/public/assets/flags/gy.svg +9 -0
  121. package/public/assets/flags/hk.svg +8 -0
  122. package/public/assets/flags/hm.svg +8 -0
  123. package/public/assets/flags/hn.svg +18 -0
  124. package/public/assets/flags/hr.svg +58 -0
  125. package/public/assets/flags/ht.svg +116 -0
  126. package/public/assets/flags/hu.svg +7 -0
  127. package/public/assets/flags/ic.svg +7 -0
  128. package/public/assets/flags/id.svg +4 -0
  129. package/public/assets/flags/ie.svg +7 -0
  130. package/public/assets/flags/il.svg +14 -0
  131. package/public/assets/flags/im.svg +36 -0
  132. package/public/assets/flags/in.svg +25 -0
  133. package/public/assets/flags/io.svg +130 -0
  134. package/public/assets/flags/iq.svg +10 -0
  135. package/public/assets/flags/ir.svg +219 -0
  136. package/public/assets/flags/is.svg +12 -0
  137. package/public/assets/flags/it.svg +7 -0
  138. package/public/assets/flags/je.svg +62 -0
  139. package/public/assets/flags/jm.svg +8 -0
  140. package/public/assets/flags/jo.svg +16 -0
  141. package/public/assets/flags/jp.svg +11 -0
  142. package/public/assets/flags/ke.svg +23 -0
  143. package/public/assets/flags/kg.svg +4 -0
  144. package/public/assets/flags/kh.svg +61 -0
  145. package/public/assets/flags/ki.svg +36 -0
  146. package/public/assets/flags/km.svg +16 -0
  147. package/public/assets/flags/kn.svg +14 -0
  148. package/public/assets/flags/kp.svg +15 -0
  149. package/public/assets/flags/kr.svg +24 -0
  150. package/public/assets/flags/kw.svg +13 -0
  151. package/public/assets/flags/ky.svg +103 -0
  152. package/public/assets/flags/kz.svg +36 -0
  153. package/public/assets/flags/la.svg +12 -0
  154. package/public/assets/flags/lb.svg +15 -0
  155. package/public/assets/flags/lc.svg +8 -0
  156. package/public/assets/flags/li.svg +43 -0
  157. package/public/assets/flags/lk.svg +22 -0
  158. package/public/assets/flags/lr.svg +14 -0
  159. package/public/assets/flags/ls.svg +8 -0
  160. package/public/assets/flags/lt.svg +7 -0
  161. package/public/assets/flags/lu.svg +5 -0
  162. package/public/assets/flags/lv.svg +6 -0
  163. package/public/assets/flags/ly.svg +13 -0
  164. package/public/assets/flags/ma.svg +4 -0
  165. package/public/assets/flags/mc.svg +6 -0
  166. package/public/assets/flags/md.svg +70 -0
  167. package/public/assets/flags/me.svg +116 -0
  168. package/public/assets/flags/mf.svg +5 -0
  169. package/public/assets/flags/mg.svg +7 -0
  170. package/public/assets/flags/mh.svg +7 -0
  171. package/public/assets/flags/mk.svg +5 -0
  172. package/public/assets/flags/ml.svg +7 -0
  173. package/public/assets/flags/mm.svg +12 -0
  174. package/public/assets/flags/mn.svg +14 -0
  175. package/public/assets/flags/mo.svg +9 -0
  176. package/public/assets/flags/mp.svg +86 -0
  177. package/public/assets/flags/mq.svg +5 -0
  178. package/public/assets/flags/mr.svg +6 -0
  179. package/public/assets/flags/ms.svg +29 -0
  180. package/public/assets/flags/mt.svg +58 -0
  181. package/public/assets/flags/mu.svg +8 -0
  182. package/public/assets/flags/mv.svg +6 -0
  183. package/public/assets/flags/mw.svg +10 -0
  184. package/public/assets/flags/mx.svg +382 -0
  185. package/public/assets/flags/my.svg +26 -0
  186. package/public/assets/flags/mz.svg +21 -0
  187. package/public/assets/flags/na.svg +16 -0
  188. package/public/assets/flags/nc.svg +13 -0
  189. package/public/assets/flags/ne.svg +6 -0
  190. package/public/assets/flags/nf.svg +9 -0
  191. package/public/assets/flags/ng.svg +6 -0
  192. package/public/assets/flags/ni.svg +129 -0
  193. package/public/assets/flags/nl.svg +5 -0
  194. package/public/assets/flags/no.svg +7 -0
  195. package/public/assets/flags/np.svg +13 -0
  196. package/public/assets/flags/nr.svg +12 -0
  197. package/public/assets/flags/nu.svg +10 -0
  198. package/public/assets/flags/nz.svg +36 -0
  199. package/public/assets/flags/om.svg +115 -0
  200. package/public/assets/flags/pa.svg +14 -0
  201. package/public/assets/flags/pc.svg +33 -0
  202. package/public/assets/flags/pe.svg +4 -0
  203. package/public/assets/flags/pf.svg +19 -0
  204. package/public/assets/flags/pg.svg +9 -0
  205. package/public/assets/flags/ph.svg +6 -0
  206. package/public/assets/flags/pk.svg +15 -0
  207. package/public/assets/flags/pl.svg +6 -0
  208. package/public/assets/flags/pm.svg +5 -0
  209. package/public/assets/flags/pn.svg +53 -0
  210. package/public/assets/flags/pr.svg +13 -0
  211. package/public/assets/flags/ps.svg +6 -0
  212. package/public/assets/flags/pt.svg +57 -0
  213. package/public/assets/flags/pw.svg +11 -0
  214. package/public/assets/flags/py.svg +157 -0
  215. package/public/assets/flags/qa.svg +4 -0
  216. package/public/assets/flags/re.svg +5 -0
  217. package/public/assets/flags/ro.svg +7 -0
  218. package/public/assets/flags/rs.svg +292 -0
  219. package/public/assets/flags/ru.svg +5 -0
  220. package/public/assets/flags/rw.svg +13 -0
  221. package/public/assets/flags/sa.svg +25 -0
  222. package/public/assets/flags/sb.svg +13 -0
  223. package/public/assets/flags/sc.svg +7 -0
  224. package/public/assets/flags/sd.svg +13 -0
  225. package/public/assets/flags/se.svg +4 -0
  226. package/public/assets/flags/sg.svg +13 -0
  227. package/public/assets/flags/sh-ac.svg +689 -0
  228. package/public/assets/flags/sh-hl.svg +164 -0
  229. package/public/assets/flags/sh-ta.svg +76 -0
  230. package/public/assets/flags/sh.svg +7 -0
  231. package/public/assets/flags/si.svg +18 -0
  232. package/public/assets/flags/sj.svg +7 -0
  233. package/public/assets/flags/sk.svg +9 -0
  234. package/public/assets/flags/sl.svg +7 -0
  235. package/public/assets/flags/sm.svg +75 -0
  236. package/public/assets/flags/sn.svg +8 -0
  237. package/public/assets/flags/so.svg +11 -0
  238. package/public/assets/flags/sr.svg +6 -0
  239. package/public/assets/flags/ss.svg +8 -0
  240. package/public/assets/flags/st.svg +16 -0
  241. package/public/assets/flags/sv.svg +593 -0
  242. package/public/assets/flags/sx.svg +56 -0
  243. package/public/assets/flags/sy.svg +6 -0
  244. package/public/assets/flags/sz.svg +34 -0
  245. package/public/assets/flags/tc.svg +50 -0
  246. package/public/assets/flags/td.svg +7 -0
  247. package/public/assets/flags/tf.svg +15 -0
  248. package/public/assets/flags/tg.svg +14 -0
  249. package/public/assets/flags/th.svg +7 -0
  250. package/public/assets/flags/tj.svg +22 -0
  251. package/public/assets/flags/tk.svg +5 -0
  252. package/public/assets/flags/tl.svg +13 -0
  253. package/public/assets/flags/tm.svg +204 -0
  254. package/public/assets/flags/tn.svg +4 -0
  255. package/public/assets/flags/to.svg +10 -0
  256. package/public/assets/flags/tr.svg +8 -0
  257. package/public/assets/flags/tt.svg +5 -0
  258. package/public/assets/flags/tv.svg +9 -0
  259. package/public/assets/flags/tw.svg +34 -0
  260. package/public/assets/flags/tz.svg +13 -0
  261. package/public/assets/flags/ua.svg +6 -0
  262. package/public/assets/flags/ug.svg +30 -0
  263. package/public/assets/flags/um.svg +9 -0
  264. package/public/assets/flags/un.svg +16 -0
  265. package/public/assets/flags/us.svg +9 -0
  266. package/public/assets/flags/uy.svg +28 -0
  267. package/public/assets/flags/uz.svg +30 -0
  268. package/public/assets/flags/va.svg +190 -0
  269. package/public/assets/flags/vc.svg +8 -0
  270. package/public/assets/flags/ve.svg +26 -0
  271. package/public/assets/flags/vg.svg +59 -0
  272. package/public/assets/flags/vi.svg +28 -0
  273. package/public/assets/flags/vn.svg +11 -0
  274. package/public/assets/flags/vu.svg +21 -0
  275. package/public/assets/flags/wf.svg +5 -0
  276. package/public/assets/flags/ws.svg +7 -0
  277. package/public/assets/flags/xk.svg +5 -0
  278. package/public/assets/flags/xx.svg +4 -0
  279. package/public/assets/flags/ye.svg +7 -0
  280. package/public/assets/flags/yt.svg +5 -0
  281. package/public/assets/flags/za.svg +17 -0
  282. package/public/assets/flags/zm.svg +27 -0
  283. package/public/assets/flags/zw.svg +21 -0
  284. package/public/assets/fonts/Inter-OFL.txt +93 -0
  285. package/public/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  286. package/public/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  287. package/public/assets/icons.svg +200 -0
  288. package/public/assets/nyx-logo-256.png +0 -0
  289. package/public/assets/tacilytics-mark.svg +16 -0
  290. package/public/index.html +239 -0
  291. package/public/style.css +415 -0
  292. package/public/tacilytics.js +184 -0
  293. package/sdk/flutter/CHANGELOG.md +6 -0
  294. package/sdk/flutter/LICENSE +21 -0
  295. package/sdk/flutter/README.md +41 -0
  296. package/sdk/flutter/dart_test.yaml +3 -0
  297. package/sdk/flutter/lib/nyx_analytics.dart +13 -0
  298. package/sdk/flutter/lib/src/beacon.dart +87 -0
  299. package/sdk/flutter/lib/src/client.dart +177 -0
  300. package/sdk/flutter/lib/tacilytics.dart +13 -0
  301. package/sdk/flutter/pubspec.lock +221 -0
  302. package/sdk/flutter/pubspec.yaml +18 -0
  303. package/sdk/flutter/test/alias_test.dart +13 -0
  304. package/sdk/flutter/test/beacon_test.dart +46 -0
  305. package/sdk/flutter/test/e2e_test.dart +18 -0
  306. package/server.js +896 -0
  307. package/tz-country.js +32 -0
package/server.js ADDED
@@ -0,0 +1,896 @@
1
+ #!/usr/bin/env node
2
+ /* Tacilytics — privacy-first, cookieless analytics for web and apps.
3
+ (tacit: we count, we don't tell.)
4
+ Express + better-sqlite3. No cookies, no cross-site tracking, IPs never stored
5
+ (only a daily, salted, one-way visitor hash). Region is inferred from the
6
+ browser timezone, never from IP geolocation. */
7
+ 'use strict';
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const express = require('express');
12
+ const Database = require('better-sqlite3');
13
+
14
+ // ── tiny .env loader (no dep) ───────────────────────────────────────────────
15
+ // Reads .env from the current directory first (npm/npx installs), then next to server.js (git checkout).
16
+ for (const dir of [...new Set([process.cwd(), __dirname])]) {
17
+ try {
18
+ const env = fs.readFileSync(path.join(dir, '.env'), 'utf8');
19
+ for (const line of env.split('\n')) {
20
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
21
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
22
+ }
23
+ } catch {}
24
+ }
25
+
26
+ const PORT = parseInt(process.env.PORT) || 3920;
27
+ const WEB_PASSWORD = process.env.WEB_PASSWORD || 'tacilytics';
28
+ const API_KEY = process.env.API_KEY || ''; // for agents / CLI (Bearer token)
29
+ const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(16).toString('hex');
30
+ const INGEST_SALT = process.env.INGEST_SALT || 'tacilytics-salt'; // set your own in .env — changing it splits today's visitors
31
+ // Git checkout: data/ next to server.js (as always). npm install: data/ in the directory you start from,
32
+ // never inside node_modules (an update would wipe it).
33
+ const INSIDE_NODE_MODULES = __dirname.split(path.sep).includes('node_modules');
34
+ const DB_PATH = process.env.DB_PATH || path.join(INSIDE_NODE_MODULES ? process.cwd() : __dirname, 'data', 'analytics.db');
35
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
36
+ if (!process.env.WEB_PASSWORD) console.warn('⚠ WEB_PASSWORD is not set — the dashboard uses the default passphrase "tacilytics". Set your own in .env.');
37
+ const RETENTION_DAYS = parseInt(process.env.DATA_RETENTION_DAYS, 10) || 0; // 0 = keep forever; >0 purges older raw events (data minimisation)
38
+
39
+ // ── DB ──────────────────────────────────────────────────────────────────────
40
+ const db = new Database(DB_PATH);
41
+ db.pragma('journal_mode = WAL');
42
+ db.exec(`
43
+ CREATE TABLE IF NOT EXISTS sites (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ domain TEXT UNIQUE NOT NULL,
46
+ name TEXT,
47
+ created INTEGER NOT NULL
48
+ );
49
+ CREATE TABLE IF NOT EXISTS events (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ site_id INTEGER NOT NULL,
52
+ ts INTEGER NOT NULL,
53
+ day TEXT NOT NULL,
54
+ path TEXT NOT NULL,
55
+ source TEXT,
56
+ referrer TEXT,
57
+ country TEXT,
58
+ tz TEXT,
59
+ browser TEXT,
60
+ os TEXT,
61
+ device TEXT,
62
+ lang TEXT,
63
+ name TEXT,
64
+ vh TEXT NOT NULL
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_events_site_ts ON events(site_id, ts);
67
+ CREATE INDEX IF NOT EXISTS idx_events_site_day ON events(site_id, day);
68
+ CREATE INDEX IF NOT EXISTS idx_events_vh ON events(site_id, vh, day);
69
+ `);
70
+ // additive migrations: each column is added once for installs created before it existed.
71
+ function ensureColumn(name, decl) {
72
+ if (!db.prepare('PRAGMA table_info(events)').all().some((c) => c.name === name)) db.exec(`ALTER TABLE events ADD COLUMN ${name} ${decl}`);
73
+ }
74
+ ensureColumn('name', 'TEXT'); // custom-event / goal name
75
+ ensureColumn('utm_medium', 'TEXT'); // campaign medium (utm_medium)
76
+ ensureColumn('utm_campaign', 'TEXT'); // campaign name (utm_campaign)
77
+ ensureColumn('dur', 'INTEGER'); // engagement seconds (set by a follow-up beacon on page leave)
78
+ ensureColumn('props', 'TEXT'); // custom event properties, JSON object as text
79
+ ensureColumn('scroll', 'INTEGER'); // max scroll depth in % (set by the engagement beacon)
80
+ ensureColumn('platform', 'TEXT'); // NULL = web (tracker script); native SDKs send android / ios / …
81
+ ensureColumn('app_version', 'TEXT'); // app build reported by native SDKs, NULL for web
82
+
83
+ // share links + funnels live in their own small tables (additive, safe on old installs)
84
+ db.exec(`
85
+ CREATE TABLE IF NOT EXISTS shares (
86
+ token TEXT PRIMARY KEY,
87
+ site_id INTEGER NOT NULL,
88
+ created INTEGER NOT NULL
89
+ );
90
+ -- visitors that touched an internal path (CMS preview etc.) are skipped for the rest of that day
91
+ CREATE TABLE IF NOT EXISTS internal_vh (
92
+ site_id INTEGER NOT NULL,
93
+ vh TEXT NOT NULL,
94
+ day TEXT NOT NULL,
95
+ PRIMARY KEY (site_id, vh, day)
96
+ );
97
+ CREATE TABLE IF NOT EXISTS funnels (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ site_id INTEGER NOT NULL,
100
+ name TEXT NOT NULL,
101
+ steps TEXT NOT NULL, -- JSON array of {type:'page'|'goal', value}
102
+ created INTEGER NOT NULL
103
+ );
104
+ `);
105
+
106
+ const norm = (d) => String(d || '').toLowerCase().replace(/^www\./, '').replace(/\/.*$/, '').trim();
107
+ const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
108
+ const insSite = db.prepare('INSERT OR IGNORE INTO sites (domain, name, created) VALUES (?, ?, ?)');
109
+ const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
110
+ const insEvent = db.prepare(`INSERT INTO events (site_id, ts, day, path, source, referrer, country, tz, browser, os, device, lang, name, utm_medium, utm_campaign, dur, props, vh, platform, app_version)
111
+ VALUES (@site_id, @ts, @day, @path, @source, @referrer, @country, @tz, @browser, @os, @device, @lang, @name, @utm_medium, @utm_campaign, @dur, @props, @vh, @platform, @app_version)`);
112
+ // engagement: attach a visit duration (seconds) to the visitor's most recent pageview for that path.
113
+ const updDur = db.prepare(`UPDATE events SET dur = MAX(COALESCE(dur, 0), @dur), scroll = CASE WHEN @scroll IS NULL THEN scroll ELSE MAX(COALESCE(scroll, 0), @scroll) END WHERE id = (
114
+ SELECT id FROM events WHERE site_id = @site_id AND vh = @vh AND name IS NULL AND path = @path AND ts >= @since
115
+ ORDER BY id DESC LIMIT 1)`);
116
+
117
+ // ── internal traffic (13.10.2026) ────────────────────────────────────────────
118
+ // Paths that only the shop team opens (CMS preview, admin). A visitor who hits one is
119
+ // treated as internal for the rest of that day and no longer counted. Per-domain list,
120
+ // extend via INTERNAL_PATHS="domain:/prefix,domain:/prefix2".
121
+ const INTERNAL_PATHS = { 'pastaclean.tv': ['/rhweb/', '/admin', '/widgets/'] };
122
+ for (const part of String(process.env.INTERNAL_PATHS || '').split(',')) {
123
+ const [d, pre] = part.split(':');
124
+ if (d && pre) (INTERNAL_PATHS[d.trim()] || (INTERNAL_PATHS[d.trim()] = [])).push(pre.trim());
125
+ }
126
+ function backfillInternal() {
127
+ for (const [d, prefixes] of Object.entries(INTERNAL_PATHS)) {
128
+ const site = db.prepare('SELECT id FROM sites WHERE domain = ?').get(d);
129
+ if (!site) continue;
130
+ for (const pre of prefixes) {
131
+ db.prepare(`INSERT OR IGNORE INTO internal_vh (site_id, vh, day) SELECT DISTINCT site_id, vh, day FROM events WHERE site_id = ? AND path >= ? AND path < ?`).run(site.id, pre, pre + '\uffff');
132
+ }
133
+ }
134
+ }
135
+ const insInternal = db.prepare('INSERT OR IGNORE INTO internal_vh (site_id, vh, day) VALUES (?, ?, ?)');
136
+ const isInternal = db.prepare('SELECT 1 FROM internal_vh WHERE site_id = ? AND vh = ? AND day = ?');
137
+
138
+ // ── payment providers (13.10.2026) ───────────────────────────────────────────
139
+ // Customers coming back from PayPal/Klarna are not a traffic source. Raw rows keep the
140
+ // host (no data loss); the sources panel hides them and shows them as a separate note.
141
+ const PAYMENT_HOSTS = ['paypal.com', 'klarna.com', 'js.klarna.com', 'x.klarnacdn.net', 'mollie.com', 'pay.amazon.com', 'payments.amazon.de', 'payments.amazon.com', 'stripe.com', 'checkout.stripe.com', 'sofort.com', 'giropay.de', 'paydirekt.de', 'ideal.nl', 'pay.google.com', 'unzer.com', 'computop-paygate.com', 'secure.ogone.com', 'ratepay.com', 'pay.payone.com', 'mollie.ideal.nl'];
142
+ backfillInternal();
143
+ const PAYMENT_SQL = PAYMENT_HOSTS.map(() => '?').join(',');
144
+ function isPaymentHost(h) { return PAYMENT_HOSTS.includes(h) || /(^|\.)(paypal|klarna|mollie|stripe|sofort|giropay|unzer|ratepay|payone)\./.test(h); }
145
+
146
+ // Custom event properties stay developer-defined and small — never a place for PII to pile up.
147
+ function sanitizeProps(raw) {
148
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
149
+ const out = {};
150
+ let n = 0;
151
+ for (const k of Object.keys(raw)) {
152
+ if (n++ >= 12) break; // cap key count
153
+ const key = String(k).slice(0, 40).trim();
154
+ if (!key) continue;
155
+ const v = raw[k];
156
+ if (v == null || typeof v === 'object') continue; // scalars only
157
+ out[key] = String(v).slice(0, 80);
158
+ }
159
+ const keys = Object.keys(out);
160
+ if (!keys.length) return null;
161
+ const json = JSON.stringify(out);
162
+ return json.length > 800 ? null : json; // hard cap on total size
163
+ }
164
+
165
+ // ── native apps (13.10.2026) ────────────────────────────────────────────────
166
+ // Apps send the same beacon as the web tracker plus `pf` (platform) and optionally `av`
167
+ // (app version), `dv` (mobile|tablet) and `cc` (2-letter region of the device locale, used
168
+ // only when the timezone gives no country). Web rows keep platform NULL, so old data is web.
169
+ const APP_PLATFORMS = { android: 'Android', ios: 'iOS', ipados: 'iOS', macos: 'macOS', windows: 'Windows', linux: 'Linux' };
170
+ const PLATFORM_SQL = "COALESCE(platform, 'web')";
171
+ function appPlatform(b) {
172
+ const pf = String(b.pf || b.platform || '').toLowerCase().trim();
173
+ return Object.prototype.hasOwnProperty.call(APP_PLATFORMS, pf) ? (pf === 'ipados' ? 'ios' : pf) : null;
174
+ }
175
+
176
+ // ── UA parsing + bot filter ─────────────────────────────────────────────────
177
+ const BOT_RE = /bot|crawl|spider|slurp|bing|google|yandex|baidu|duckduck|facebookexternal|embedly|quora|pinterest|slackbot|telegrambot|whatsapp|twitter|discordbot|preview|monitor|uptime|pingdom|lighthouse|headless|phantom|curl|wget|python-requests|axios|node-fetch|go-http/i;
178
+ function parseUA(ua) {
179
+ ua = ua || '';
180
+ if (!ua || BOT_RE.test(ua)) return null; // drop bots/crawlers entirely
181
+ let browser = 'Other', os = 'Other', device = 'Desktop';
182
+ if (/iPad/i.test(ua) || (/Tablet/i.test(ua) && !/Mobile/i.test(ua))) device = 'Tablet';
183
+ else if (/Mobi|Android|iPhone|iPod/i.test(ua)) device = 'Mobile';
184
+ if (/Windows NT/i.test(ua)) os = 'Windows';
185
+ else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS';
186
+ else if (/Mac OS X/i.test(ua)) os = 'macOS';
187
+ else if (/Android/i.test(ua)) os = 'Android';
188
+ else if (/CrOS/i.test(ua)) os = 'ChromeOS';
189
+ else if (/Linux/i.test(ua)) os = 'Linux';
190
+ if (/Edg\//i.test(ua)) browser = 'Edge';
191
+ else if (/OPR\/|Opera/i.test(ua)) browser = 'Opera';
192
+ else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung';
193
+ else if (/Firefox\//i.test(ua)) browser = 'Firefox';
194
+ else if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua)) browser = 'Chrome';
195
+ else if (/Safari\//i.test(ua) && /Version\//i.test(ua)) browser = 'Safari';
196
+ return { browser, os, device };
197
+ }
198
+
199
+ // Rough timezone → ISO country (privacy-preserving alternative to IP geo).
200
+ const TZ_COUNTRY = require('./tz-country.js');
201
+ function countryFromTz(tz) { return (tz && TZ_COUNTRY[tz]) || null; }
202
+
203
+ const { createDaySalt, dayStr } = require('./day-salt.js');
204
+ // Daily salt: fresh random secret per UTC day in data/.day-salt (0600), never in the DB.
205
+ // Rules and the single-process note live in day-salt.js.
206
+ const SALT_FILE = path.join(path.dirname(DB_PATH), '.day-salt');
207
+ const todaysSalt = createDaySalt(SALT_FILE);
208
+ // one-time cleanup of the short-lived DB table from 03934ed: zero the pages, flush the WAL
209
+ db.pragma('secure_delete = ON');
210
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='daily_salt'").get()) {
211
+ db.exec('DELETE FROM daily_salt; DROP TABLE daily_salt;');
212
+ db.pragma('wal_checkpoint(TRUNCATE)');
213
+ }
214
+ function visitorHash(siteId, ip, ua, day) {
215
+ const salt = day === dayStr(Date.now()) ? todaysSalt() : null;
216
+ if (!salt) return crypto.randomBytes(12).toString('hex'); // unlinkable one-off visitor
217
+ return crypto.createHash('sha256').update(`${INGEST_SALT}|${salt}|${day}|${siteId}|${ip}|${ua}`).digest('hex').slice(0, 24);
218
+ }
219
+ function clientIp(req) {
220
+ const xff = (req.headers['x-forwarded-for'] || '').split(',')[0].trim();
221
+ return xff || req.socket.remoteAddress || '0.0.0.0';
222
+ }
223
+
224
+ // ── app ─────────────────────────────────────────────────────────────────────
225
+ const app = express();
226
+ app.set('trust proxy', 1);
227
+ // Accept both application/json (dashboard) and text/plain (cross-origin beacons).
228
+ app.use(express.json({ limit: '32kb', type: ['application/json', 'text/plain'] }));
229
+ app.disable('x-powered-by');
230
+
231
+ // Lightweight in-memory rate limiter (fixed window per key). No external dep.
232
+ const rl = new Map();
233
+ function rateLimited(key, max, windowMs) {
234
+ const now = Date.now();
235
+ let e = rl.get(key);
236
+ if (!e || e.reset < now) { e = { n: 0, reset: now + windowMs }; rl.set(key, e); }
237
+ e.n++;
238
+ return e.n > max;
239
+ }
240
+ setInterval(() => { const now = Date.now(); for (const [k, v] of rl) if (v.reset < now) rl.delete(k); }, 60000).unref();
241
+
242
+ // ── tracking script (served with permissive CORS so any site can embed) ──────
243
+ const TRACKER = fs.readFileSync(path.join(__dirname, 'public', 'tacilytics.js'), 'utf8');
244
+ // /nyx.js stays as an alias: sites embedded before the rename keep working unchanged.
245
+ app.get(['/tacilytics.js', '/nyx.js'], (_req, res) => {
246
+ res.set('Content-Type', 'application/javascript; charset=utf-8');
247
+ res.set('Cache-Control', 'public, max-age=86400');
248
+ res.set('Access-Control-Allow-Origin', '*');
249
+ res.send(TRACKER);
250
+ });
251
+
252
+ // ── collection endpoint ─────────────────────────────────────────────────────
253
+ function collect(req, res) {
254
+ res.set('Access-Control-Allow-Origin', '*');
255
+ res.set('Access-Control-Allow-Headers', 'Content-Type');
256
+ // flood / stat-poisoning guard: cap events per IP per minute (legit SPA bursts fit easily)
257
+ if (rateLimited('c:' + clientIp(req), 600, 60000)) return res.status(204).end();
258
+ const b = req.method === 'GET' ? req.query : (req.body || {});
259
+ const domain = norm(b.d || b.domain);
260
+ if (!domain) return res.status(204).end();
261
+ const site = getSiteByDomain.get(domain);
262
+ if (!site) return res.status(202).end(); // unknown site → silently ignore
263
+ const platform = appPlatform(b);
264
+ // apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
265
+ // A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
266
+ const rawUa = req.headers['user-agent'] || '';
267
+ let ua = parseUA(rawUa);
268
+ if (platform && rawUa && !BOT_RE.test(rawUa)) {
269
+ const dv = String(b.dv || '').toLowerCase();
270
+ ua = { browser: 'App', os: APP_PLATFORMS[platform], device: dv === 'tablet' ? 'Tablet' : dv === 'desktop' ? 'Desktop' : (platform === 'android' || platform === 'ios') ? 'Mobile' : 'Desktop' };
271
+ }
272
+ if (!ua) return res.status(204).end(); // bot
273
+
274
+ const ts = Date.now();
275
+ const day = dayStr(ts);
276
+ let pth = String(b.p || b.path || '/').slice(0, 512);
277
+ try { pth = decodeURI(pth); } catch {}
278
+ if (pth.length > 1) pth = pth.replace(/\/+$/, '') || '/';
279
+ const ip = clientIp(req);
280
+ // apps may send `sid`: a random id the SDK draws per app launch and never persists (no device id).
281
+ // Mobile carriers put many phones behind one IP, so IP+UA alone would merge app users.
282
+ const sid = platform ? String(b.sid || '').slice(0, 64) : '';
283
+ const vh = sid && /^[A-Za-z0-9_-]{16,64}$/.test(sid)
284
+ ? visitorHash(site.id, 'app:' + sid, platform, day)
285
+ : visitorHash(site.id, ip, req.headers['user-agent'] || '', day);
286
+
287
+ // internal visitor (CMS preview etc.): mark for the day and drop everything from them.
288
+ const intern = INTERNAL_PATHS[domain] || [];
289
+ if (intern.some((pre) => pth.startsWith(pre))) { insInternal.run(site.id, vh, day); return res.status(204).end(); }
290
+ if (isInternal.get(site.id, vh, day)) return res.status(204).end();
291
+
292
+ // engagement beacon: attach a visit duration (+ max scroll depth) to the visitor's latest pageview (no new row).
293
+ if (b.e === 'engagement') {
294
+ const dur = Math.max(0, Math.min(7200, parseInt(b.dur, 10) || 0)); // cap at 2h
295
+ const sc = b.sc == null || b.sc === '' ? null : Math.max(0, Math.min(100, parseInt(b.sc, 10) || 0));
296
+ if (dur > 0 || sc != null) updDur.run({ site_id: site.id, vh, path: pth, dur, scroll: sc, since: ts - 6 * 3600000 });
297
+ return res.status(204).end();
298
+ }
299
+
300
+ // referrer → source host
301
+ let referrer = String(b.r || b.referrer || '').slice(0, 512);
302
+ let source = 'Direct';
303
+ const utm = String(b.s || b.utm || '').slice(0, 60);
304
+ if (utm) source = utm;
305
+ else if (referrer) {
306
+ try { const h = norm(new URL(referrer).hostname); if (h && h !== domain) source = h; } catch {}
307
+ }
308
+ const utmMedium = String(b.um || b.utm_medium || '').slice(0, 60).trim() || null;
309
+ const utmCampaign = String(b.uc || b.utm_campaign || '').slice(0, 80).trim() || null;
310
+ const tz = String(b.tz || '').slice(0, 60) || null;
311
+ const lang = String(b.l || b.lang || '').slice(0, 12).split(',')[0] || null;
312
+ const name = String(b.n || b.name || '').slice(0, 80).trim() || null; // custom event / goal
313
+ const props = name ? sanitizeProps(b.pr) : null; // properties only attach to named events
314
+ let country = countryFromTz(tz);
315
+ if (!country && platform) { const cc = String(b.cc || '').toUpperCase(); if (/^[A-Z]{2}$/.test(cc)) country = cc; }
316
+ const appVersion = platform ? (String(b.av || '').slice(0, 32).trim() || null) : null;
317
+ insEvent.run({
318
+ site_id: site.id, ts, day, path: pth, source, referrer: referrer || null,
319
+ country, tz, browser: ua.browser, os: ua.os, device: ua.device,
320
+ lang, name, utm_medium: utmMedium, utm_campaign: utmCampaign, dur: null, props, vh,
321
+ platform, app_version: appVersion,
322
+ });
323
+ res.status(204).end();
324
+ }
325
+ app.options('/api/collect', (_req, res) => { res.set('Access-Control-Allow-Origin', '*').set('Access-Control-Allow-Headers', 'Content-Type').status(204).end(); });
326
+ app.post('/api/collect', collect);
327
+ app.get('/api/collect', collect);
328
+
329
+ // ── auth (password → in-memory session token) ──────────────
330
+ const sessions = new Map();
331
+ function newToken() { return crypto.randomBytes(24).toString('hex'); }
332
+ app.post('/auth/login', (req, res) => {
333
+ if (rateLimited('login:' + clientIp(req), 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
334
+ const pw = String((req.body && req.body.password) || '');
335
+ const ok = pw.length === WEB_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(pw), Buffer.from(WEB_PASSWORD));
336
+ if (!ok) return res.status(401).json({ error: 'Wrong password' });
337
+ const token = newToken();
338
+ sessions.set(token, { expires: Date.now() + 7 * 864e5 });
339
+ res.json({ token });
340
+ });
341
+ function checkApiKey(req) {
342
+ if (!API_KEY) return false;
343
+ const hdr = String(req.headers['authorization'] || '');
344
+ const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
345
+ return key.length === API_KEY.length && crypto.timingSafeEqual(Buffer.from(key), Buffer.from(API_KEY));
346
+ }
347
+ function auth(req, res, next) {
348
+ if (checkApiKey(req)) return next(); // agents / CLI
349
+ const t = req.headers['x-session-token'];
350
+ const s = t && sessions.get(t);
351
+ if (s && s.expires > Date.now()) return next();
352
+ if (s) sessions.delete(t);
353
+ res.status(401).json({ error: 'Not authenticated' });
354
+ }
355
+
356
+ // share-token auth: read-only stats access scoped to exactly one site.
357
+ // Accepted as ?share=<token>; the handler must verify req.shareSite matches the queried site.
358
+ const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
359
+ function authOrShare(req, res, next) {
360
+ const tok = String(req.query.share || '');
361
+ if (/^[a-f0-9]{32,64}$/.test(tok)) {
362
+ const sh = getShare.get(tok);
363
+ if (sh) { req.shareSiteId = sh.site_id; return next(); }
364
+ }
365
+ return auth(req, res, next);
366
+ }
367
+ function shareScopeOk(req, site) { return req.shareSiteId == null || req.shareSiteId === site.id; }
368
+
369
+ // ── sites management ─────────────────────────────────────────────────────────
370
+ const sitesListHandler = (_req, res) => res.json(allSites.all());
371
+ const sitesCreateHandler = (req, res) => {
372
+ const rawDomain = norm(req.body && req.body.domain);
373
+ let domain = rawDomain;
374
+ let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
375
+ // IDN support: convert Unicode/umlaut domains to Punycode (stored), keep Unicode as display name
376
+ if (domain && /[^\x00-\x7F]/.test(domain)) {
377
+ try { domain = new URL('http://' + domain).hostname; } catch (_) { /* fall through to validation */ }
378
+ }
379
+ // Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
380
+ if (!domain || !/^[a-z0-9.-]+\.[a-z0-9-]{2,}$/.test(domain)) return res.status(400).json({ error: 'Enter a valid domain, e.g. example.com' });
381
+ insSite.run(domain, (req.body && req.body.name) || displayName, Date.now());
382
+ res.json(getSiteByDomain.get(domain));
383
+ };
384
+ app.get('/api/sites', auth, sitesListHandler);
385
+ app.post('/api/sites', auth, sitesCreateHandler);
386
+ app.delete('/api/sites/:id', auth, (req, res) => {
387
+ const id = Number(req.params.id);
388
+ db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
389
+ db.prepare('DELETE FROM sites WHERE id = ?').run(id);
390
+ res.json({ ok: true });
391
+ });
392
+
393
+ // ── /api/v1 — stable agent/CLI surface (Authorization: Bearer <API_KEY>) ──────
394
+ app.get('/api/v1', (_req, res) => res.json({
395
+ name: 'Tacilytics API', version: '1',
396
+ auth: 'Authorization: Bearer <API_KEY>',
397
+ endpoints: {
398
+ 'GET /api/v1/sites': 'list tracked sites',
399
+ 'POST /api/v1/sites': 'add a site { domain }',
400
+ 'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
401
+ 'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
402
+ },
403
+ filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
404
+ }));
405
+ app.get('/api/v1/sites', auth, sitesListHandler);
406
+ app.post('/api/v1/sites', auth, sitesCreateHandler);
407
+
408
+ // ── stats ────────────────────────────────────────────────────────────────────
409
+ const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
410
+ function rangeFor(query) {
411
+ const now = Date.now();
412
+ // custom range: from/to are YYYY-MM-DD (inclusive)
413
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(query.from || '') ? Date.parse(query.from + 'T00:00:00Z') : null;
414
+ if (from != null && !Number.isNaN(from)) {
415
+ const toRaw = /^\d{4}-\d{2}-\d{2}$/.test(query.to || '') ? Date.parse(query.to + 'T00:00:00Z') : from;
416
+ const end = Math.min(now, (Number.isNaN(toRaw) ? from : toRaw) + 864e5); // inclusive end day
417
+ const days = Math.max(1, Math.round((end - from) / 864e5));
418
+ return { start: from, end, days, period: 'custom', hourly: days <= 1 };
419
+ }
420
+ const period = PERIODS[query.period] ? query.period : '7d';
421
+ const days = PERIODS[period];
422
+ // Whole UTC days: "7d" = today + the 6 full days before it. Starting at now - 7×24h would
423
+ // cut the oldest day off at the current clock time and undercount it.
424
+ const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
425
+ const start = today0 - (days - 1) * 864e5;
426
+ return { start, end: now, days, period, hourly: period === 'today' };
427
+ }
428
+
429
+ // Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
430
+ // Every value is bound as a parameter; only these exact column names are ever interpolated.
431
+ const FILTER_COLS = { country: 'country', source: 'source', page: 'path', browser: 'browser', os: 'os', device: 'device', lang: 'lang', medium: 'utm_medium', campaign: 'utm_campaign', platform: PLATFORM_SQL, version: 'app_version' };
432
+ function buildFilters(query) {
433
+ const clauses = [], params = [];
434
+ for (const key of Object.keys(FILTER_COLS)) {
435
+ const v = query[key];
436
+ if (v != null && v !== '') { clauses.push(`${FILTER_COLS[key]} = ?`); params.push(String(v).slice(0, 200)); }
437
+ }
438
+ const goal = query.goal != null && query.goal !== '' ? String(query.goal).slice(0, 80) : null;
439
+ return { clauses, params, goal };
440
+ }
441
+
442
+ // Validated values only (whitelist / strict pattern), so they can be inlined into event queries.
443
+ function eventPlatformFilter(query) {
444
+ let f = '';
445
+ const pf = String(query.platform || '').toLowerCase();
446
+ if (pf === 'web' || Object.prototype.hasOwnProperty.call(APP_PLATFORMS, pf)) f += ` AND ${PLATFORM_SQL} = '${pf}'`;
447
+ const v = String(query.version || '');
448
+ if (/^[A-Za-z0-9._+-]{1,32}$/.test(v)) f += ` AND app_version = '${v}'`;
449
+ return f;
450
+ }
451
+
452
+ function statsHandler(req, res) {
453
+ const site = getSiteByDomain.get(norm(req.query.site));
454
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
455
+ if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
456
+ const sid = site.id;
457
+ const { start, end, days, period, hourly } = rangeFor(req.query);
458
+ const { clauses, params, goal } = buildFilters(req.query);
459
+ const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
460
+
461
+ // pageview WHERE = time window + segmentation filters. A goal filter narrows the visitor
462
+ // set to those who fired that event in-range (so every breakdown reflects converters only).
463
+ let where = 'site_id = ? AND ts >= ? AND ts < ? AND name IS NULL AND NOT EXISTS (SELECT 1 FROM internal_vh i WHERE i.site_id = events.site_id AND i.vh = events.vh AND i.day = events.day)';
464
+ const args = [sid, start, end, ...params];
465
+ if (clauses.length) where += ' AND ' + clauses.join(' AND ');
466
+ if (goal) { where += ' AND vh IN (SELECT vh FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name = ?)'; args.push(sid, start, end, goal); }
467
+
468
+ const totals = db.prepare(`SELECT COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where}`).get(...args);
469
+ const bounceRow = db.prepare(`SELECT COUNT(*) AS bounced FROM (SELECT vh, COUNT(*) c FROM events WHERE ${where} GROUP BY vh HAVING c = 1)`).get(...args);
470
+ const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
471
+ const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
472
+ const durRow = db.prepare(`SELECT AVG(dur) d FROM events WHERE ${where} AND dur IS NOT NULL`).get(...args);
473
+ const duration = durRow && durRow.d != null ? Math.round(durRow.d) : null; // avg engaged seconds
474
+
475
+ // trend vs the previous equal-length window (same filters)
476
+ const len = end - start;
477
+ const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
478
+ if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
479
+ const prev = db.prepare(`SELECT COUNT(*) pageviews, COUNT(DISTINCT vh) visitors FROM events WHERE ${where}`).get(...prevArgs);
480
+ const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
481
+
482
+ // time series (per hour for a single day, else per day)
483
+ let series;
484
+ if (hourly) {
485
+ const rows = db.prepare(`SELECT CAST((ts/3600000) AS INT) AS bucket, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY bucket`).all(...args);
486
+ const map = new Map(rows.map((r) => [r.bucket, r]));
487
+ const startH = Math.floor(start / 3600000);
488
+ series = Array.from({ length: 24 }, (_, i) => {
489
+ const r = map.get(startH + i);
490
+ return { label: String(i).padStart(2, '0'), visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
491
+ });
492
+ } else {
493
+ const rows = db.prepare(`SELECT day, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY day`).all(...args);
494
+ const map = new Map(rows.map((r) => [r.day, r]));
495
+ series = Array.from({ length: days }, (_, i) => {
496
+ const d = new Date(start + i * 864e5).toISOString().slice(0, 10);
497
+ const r = map.get(d);
498
+ return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
499
+ });
500
+ }
501
+
502
+ // previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
503
+ const yoyStart = start - 364 * 864e5, yoyEnd = end - 364 * 864e5;
504
+ const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
505
+ let yoy = null;
506
+ if (firstTs != null && firstTs <= yoyStart) {
507
+ const ya = args.slice(); ya[1] = yoyStart; ya[2] = yoyEnd;
508
+ if (goal) { ya[ya.length - 3] = yoyStart; ya[ya.length - 2] = yoyEnd; }
509
+ const y = db.prepare(`SELECT COUNT(*) pageviews, COUNT(DISTINCT vh) visitors FROM events WHERE ${where}`).get(...ya);
510
+ yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
511
+ }
512
+
513
+ const topBy = (col, limit = 8) => db.prepare(
514
+ `SELECT ${col} AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND ${col} IS NOT NULL AND ${col} != '' GROUP BY ${col} ORDER BY visitors DESC, pageviews DESC LIMIT ${limit}`,
515
+ ).all(...args);
516
+
517
+ // entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
518
+ const edge = (dir, limit = 8) => db.prepare(
519
+ `SELECT path AS name, COUNT(*) AS visitors, COUNT(*) AS pageviews FROM (
520
+ SELECT path, ROW_NUMBER() OVER (PARTITION BY vh, day ORDER BY ts ${dir}, id ${dir}) rn FROM events WHERE ${where}
521
+ ) WHERE rn = 1 GROUP BY path ORDER BY visitors DESC LIMIT ${limit}`,
522
+ ).all(...args);
523
+
524
+ // goals: conversion = unique converters / unique visitors in the (filtered) range.
525
+ const goalRows = db.prepare(
526
+ `SELECT name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name IS NOT NULL GROUP BY name ORDER BY visitors DESC, pageviews DESC LIMIT 12`,
527
+ ).all(sid, start, end);
528
+ const goals = goalRows.map((g) => ({ ...g, cr: totals.visitors ? +((g.visitors / totals.visitors) * 100).toFixed(1) : 0 }));
529
+
530
+ // properties: only meaningful when drilled into a single goal — top values per property key.
531
+ let properties = null;
532
+ if (goal) {
533
+ const rows = db.prepare(`SELECT props FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name = ? AND props IS NOT NULL`).all(sid, start, end, goal);
534
+ const byKey = {};
535
+ for (const r of rows) {
536
+ let obj; try { obj = JSON.parse(r.props); } catch { continue; }
537
+ for (const k of Object.keys(obj || {})) {
538
+ (byKey[k] || (byKey[k] = {}));
539
+ const val = String(obj[k]);
540
+ byKey[k][val] = (byKey[k][val] || 0) + 1;
541
+ }
542
+ }
543
+ properties = Object.keys(byKey).map((k) => ({
544
+ key: k,
545
+ values: Object.entries(byKey[k]).map(([name, pageviews]) => ({ name, pageviews, visitors: pageviews })).sort((a, b) => b.pageviews - a.pageviews).slice(0, 8),
546
+ }));
547
+ }
548
+
549
+ // sources without payment providers (returning customers, not traffic) — shown separately
550
+ // one source query; payment providers (returning customers, not traffic) are split off
551
+ const srcAll = db.prepare(
552
+ `SELECT source AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND source IS NOT NULL AND source != '' GROUP BY source ORDER BY visitors DESC, pageviews DESC LIMIT 40`,
553
+ ).all(...args);
554
+ const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
555
+ const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
556
+
557
+ // ── commerce (event "Purchase" with props.value) ──
558
+ const purchases = db.prepare(
559
+ `SELECT vh, day, ${PLATFORM_SQL} AS platform, CAST(json_extract(props, '$.value') AS REAL) AS value FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name = 'Purchase'`,
560
+ ).all(sid, start, end);
561
+ let commerce = null;
562
+ if (purchases.length) {
563
+ // first non-payment source per buying visit (vh, day) — one query instead of one per order
564
+ const keys = [...new Set(purchases.map((x) => x.vh + '|' + x.day))];
565
+ const days = [...new Set(purchases.map((x) => x.day))];
566
+ const srcRows = db.prepare(
567
+ `SELECT vh, day, source FROM (
568
+ SELECT vh, day, source, ROW_NUMBER() OVER (PARTITION BY vh, day ORDER BY ts, id) rn
569
+ FROM events WHERE site_id = ? AND day IN (${days.map(() => '?').join(',')}) AND name IS NULL AND source NOT IN (${PAYMENT_SQL})
570
+ AND vh IN (SELECT vh FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name = 'Purchase')
571
+ ) WHERE rn = 1`,
572
+ ).all(sid, ...days, ...PAYMENT_HOSTS, sid, start, end);
573
+ const srcOf = new Map(srcRows.map((r) => [r.vh + '|' + r.day, r.source]));
574
+ void keys;
575
+ const bySrc = new Map(), byPf = new Map();
576
+ let revenue = 0;
577
+ for (const pch of purchases) {
578
+ const v = Number.isFinite(pch.value) ? pch.value : 0;
579
+ revenue += v;
580
+ const pf = byPf.get(pch.platform) || { name: pch.platform, orders: 0, revenue: 0 };
581
+ pf.orders++; pf.revenue += v; byPf.set(pch.platform, pf);
582
+ const src = srcOf.get(pch.vh + '|' + pch.day) || 'Direct';
583
+ const e = bySrc.get(src) || { name: src, orders: 0, revenue: 0 };
584
+ e.orders++; e.revenue += v; bySrc.set(src, e);
585
+ }
586
+ const prevRev = db.prepare(
587
+ `SELECT COUNT(*) n, SUM(CAST(json_extract(props, '$.value') AS REAL)) r FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name = 'Purchase'`,
588
+ ).get(sid, start - len, start);
589
+ const orders = purchases.length;
590
+ commerce = {
591
+ orders, revenue: +revenue.toFixed(2), aov: +(revenue / orders).toFixed(2),
592
+ cr: totals.visitors ? +((new Set(purchases.map((x) => x.vh + x.day)).size / totals.visitors) * 100).toFixed(2) : 0,
593
+ // no trend against a near-empty baseline (e.g. the week tracking started) — a % of almost nothing is noise
594
+ trend: prevRev.n >= 5 ? { orders: pct(orders, prevRev.n), revenue: pct(revenue, prevRev.r || 0) } : { orders: null, revenue: null },
595
+ byPlatform: [...byPf.values()].map((x) => ({ ...x, revenue: +x.revenue.toFixed(2), visitors: x.orders })).sort((a, b) => b.revenue - a.revenue),
596
+ bySource: [...bySrc.values()].map((x) => ({ ...x, revenue: +x.revenue.toFixed(2), visitors: x.orders })).sort((a, b) => b.revenue - a.revenue).slice(0, 10),
597
+ };
598
+ }
599
+
600
+ // ── site search (event "Search" with props.q + props.results) ──
601
+ const searchRows = db.prepare(
602
+ `SELECT LOWER(TRIM(json_extract(props, '$.q'))) q, CAST(json_extract(props, '$.results') AS INTEGER) n, vh FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name = 'Search' AND props IS NOT NULL`,
603
+ ).all(sid, start, end);
604
+ let search = null;
605
+ if (searchRows.length) {
606
+ const agg = new Map();
607
+ for (const r of searchRows) {
608
+ if (!r.q) continue;
609
+ const e = agg.get(r.q) || { name: r.q, searches: 0, zero: 0, vhs: new Set() };
610
+ e.searches++; if (r.n === 0) e.zero++; e.vhs.add(r.vh); agg.set(r.q, e);
611
+ }
612
+ const all = [...agg.values()].map((e) => ({ name: e.name, pageviews: e.searches, visitors: e.vhs.size, zero: e.zero }));
613
+ search = {
614
+ total: searchRows.length,
615
+ zeroShare: +((searchRows.filter((r) => r.n === 0).length / searchRows.length) * 100).toFixed(1),
616
+ top: all.slice().sort((a, b) => b.pageviews - a.pageviews).slice(0, 10),
617
+ zeroResults: all.filter((e) => e.zero > 0).sort((a, b) => b.zero - a.zero).slice(0, 10).map((e) => ({ ...e, pageviews: e.zero, visitors: e.zero })),
618
+ };
619
+ }
620
+
621
+ // ── broken pages (event "404" with props.url / props.from) ──
622
+ const notFound = db.prepare(
623
+ `SELECT json_extract(props, '$.url') AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors, MAX(json_extract(props, '$.from')) AS fromPage FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name = '404' GROUP BY name ORDER BY pageviews DESC LIMIT 10`,
624
+ ).all(sid, start, end);
625
+
626
+ // ── scroll depth per page (pages with enough measured views) ──
627
+ const scrollPages = db.prepare(
628
+ `SELECT path AS name, COUNT(*) AS pageviews, ROUND(AVG(scroll)) AS avg, ROUND(100.0 * SUM(scroll >= 75) / COUNT(*)) AS deep FROM events WHERE ${where} AND scroll IS NOT NULL GROUP BY path HAVING COUNT(*) >= 5 ORDER BY pageviews DESC LIMIT 10`,
629
+ ).all(...args).map((r) => ({ ...r, visitors: r.pageviews }));
630
+
631
+ res.json({
632
+ site: { domain: site.domain, name: site.name },
633
+ period, range: { from: new Date(start).toISOString().slice(0, 10), to: new Date(end - 1).toISOString().slice(0, 10) },
634
+ filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
635
+ totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
636
+ trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
637
+ previous: { visitors: prev.visitors, pageviews: prev.pageviews },
638
+ yoy,
639
+ dataSince: firstTs ? new Date(firstTs).toISOString().slice(0, 10) : null,
640
+ series,
641
+ pages: topBy('path', 10),
642
+ entryPages: edge('ASC', 8),
643
+ exitPages: edge('DESC', 8),
644
+ sources: sourcesClean,
645
+ sourcesAll: srcAll.slice(0, 8),
646
+ paymentReturns,
647
+ commerce,
648
+ search,
649
+ notFound,
650
+ scrollPages,
651
+ mediums: topBy('utm_medium', 8),
652
+ campaigns: topBy('utm_campaign', 8),
653
+ countries: topBy('country', 8),
654
+ browsers: topBy('browser', 6),
655
+ os: topBy('os', 6),
656
+ devices: topBy('device', 4),
657
+ platforms: topBy(PLATFORM_SQL, 6),
658
+ appVersions: topBy('app_version', 6),
659
+ languages: topBy('lang', 6),
660
+ goals,
661
+ properties,
662
+ realtime: db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, Date.now() - 5 * 60000).v,
663
+ });
664
+ }
665
+ app.get('/api/stats', authOrShare, statsHandler);
666
+ app.get('/api/v1/stats', authOrShare, statsHandler);
667
+
668
+ // ── realtime: live visitors + per-minute pulse + recent event feed (last 30 min) ──
669
+ function realtimeHandler(req, res) {
670
+ const site = getSiteByDomain.get(norm(req.query.site));
671
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
672
+ if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
673
+ const sid = site.id, now = Date.now(), since = now - 30 * 60000;
674
+ const online = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, now - 5 * 60000).v;
675
+ const rows = db.prepare('SELECT CAST((ts/60000) AS INT) b, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ? GROUP BY b').all(sid, since);
676
+ const map = new Map(rows.map((r) => [r.b, r]));
677
+ const startB = Math.floor(since / 60000);
678
+ const minutes = Array.from({ length: 30 }, (_, i) => { const r = map.get(startB + i + 1); return { visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 }; });
679
+ const recent = db.prepare('SELECT ts, path, country, tz, browser, os, device, source, lang, name, platform FROM events WHERE site_id = ? AND ts >= ? ORDER BY id DESC LIMIT 60').all(sid, since);
680
+ res.json({ online, minutes, recent, now });
681
+ }
682
+ app.get('/api/realtime', authOrShare, realtimeHandler);
683
+ app.get('/api/v1/realtime', authOrShare, realtimeHandler);
684
+
685
+ // ── share links: create / list / revoke (owner only) + resolve (public) ──────────
686
+ app.get('/api/shares', auth, (req, res) => {
687
+ const rows = db.prepare('SELECT s.token, s.created, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all();
688
+ res.json(rows);
689
+ });
690
+ app.post('/api/shares', auth, (req, res) => {
691
+ const site = getSiteByDomain.get(norm(req.body && req.body.site));
692
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
693
+ const token = newToken();
694
+ db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
695
+ res.json({ token, domain: site.domain, url: '/share/' + token });
696
+ });
697
+ app.delete('/api/shares/:token', auth, (req, res) => {
698
+ db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
699
+ res.json({ ok: true });
700
+ });
701
+ // public: resolve a share token to its site (used by the read-only dashboard)
702
+ app.get('/api/share-info/:token', (req, res) => {
703
+ const sh = getShare.get(String(req.params.token));
704
+ if (!sh) return res.status(404).json({ error: 'Unknown or revoked share link' });
705
+ const site = db.prepare('SELECT domain, name FROM sites WHERE id = ?').get(sh.site_id);
706
+ res.json({ domain: site.domain, name: site.name });
707
+ });
708
+
709
+ // ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
710
+ app.get('/api/funnels', auth, (req, res) => {
711
+ const site = getSiteByDomain.get(norm(req.query.site));
712
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
713
+ res.json(db.prepare('SELECT id, name, steps, created FROM funnels WHERE site_id = ? ORDER BY id').all(site.id).map((f) => ({ ...f, steps: JSON.parse(f.steps) })));
714
+ });
715
+ app.post('/api/funnels', auth, (req, res) => {
716
+ const site = getSiteByDomain.get(norm(req.body && req.body.site));
717
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
718
+ const name = String((req.body && req.body.name) || '').slice(0, 80).trim();
719
+ const raw = (req.body && req.body.steps) || [];
720
+ if (!name) return res.status(400).json({ error: 'Funnel needs a name' });
721
+ if (!Array.isArray(raw) || raw.length < 2 || raw.length > 8) return res.status(400).json({ error: 'Funnel needs 2-8 steps' });
722
+ const steps = raw.map((s) => ({
723
+ type: s && s.type === 'goal' ? 'goal' : 'page',
724
+ value: String((s && s.value) || '').slice(0, 200).trim(),
725
+ label: String((s && (s.label || s.value)) || '').slice(0, 80).trim(),
726
+ }));
727
+ if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
728
+ const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
729
+ res.json({ id: info.lastInsertRowid, name, steps });
730
+ });
731
+ app.delete('/api/funnels/:id', auth, (req, res) => {
732
+ db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
733
+ res.json({ ok: true });
734
+ });
735
+ // funnel evaluation: strictly ordered — a visitor counts for step N only after matching step N-1.
736
+ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
737
+ const f = db.prepare('SELECT * FROM funnels WHERE id = ?').get(Number(req.params.id));
738
+ if (!f) return res.status(404).json({ error: 'Unknown funnel' });
739
+ const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
740
+ if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
741
+ const { start, end } = rangeFor(req.query);
742
+ const steps = JSON.parse(f.steps);
743
+ // one pass per visitor: fetch matching events sorted by time, walk the step ladder.
744
+ const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
745
+ const rows = db.prepare(
746
+ `SELECT vh, ts, path, name FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND (${conds.join(' OR ')}) ORDER BY vh, ts, id`,
747
+ ).all(f.site_id, start, end, ...steps.map((s) => s.value));
748
+ const progress = new Map(); // vh → next step index the visitor still needs
749
+ for (const r of rows) {
750
+ const at = progress.get(r.vh) || 0;
751
+ if (at >= steps.length) continue;
752
+ const s = steps[at];
753
+ const match = s.type === 'goal' ? r.name === s.value : r.name == null && r.path === s.value;
754
+ if (match) progress.set(r.vh, at + 1);
755
+ }
756
+ const counts = steps.map(() => 0);
757
+ for (const reached of progress.values()) for (let i = 0; i < reached; i++) counts[i]++;
758
+ const out = steps.map((s, i) => ({
759
+ label: s.label || s.value, type: s.type, value: s.value, visitors: counts[i],
760
+ pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
761
+ dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
762
+ }));
763
+ res.json({ id: f.id, name: f.name, range: { from: new Date(start).toISOString().slice(0, 10), to: new Date(end - 1).toISOString().slice(0, 10) }, steps: out });
764
+ });
765
+
766
+ // ── static dashboard ─────────────────────────────────────────────────────────
767
+ app.use(express.static(path.join(__dirname, 'public')));
768
+ // ── visitor journeys: session-based path transitions for a sankey flow ───────────
769
+ // A "session" = one visitor's pageviews with < 30 min between them.
770
+ // Returns the top N transitions per depth (entry → step 2 → step 3 …), plus exits.
771
+ app.get('/api/journeys', authOrShare, (req, res) => {
772
+ const site = getSiteByDomain.get(norm(req.query.site));
773
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
774
+ if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
775
+ const { start, end } = rangeFor(req.query);
776
+ const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
777
+ const GAP = 30 * 60000;
778
+ const rows = db.prepare('SELECT vh, ts, path FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL ORDER BY vh, ts, id').all(site.id, start, end);
779
+ const links = new Map(); // "depth|from|to" → count (to = path or "✖ exit")
780
+ let sessions = 0;
781
+ let cur = null; // { vh, lastTs, steps: [paths] }
782
+ const flush = (s) => {
783
+ if (!s) return;
784
+ sessions++;
785
+ const steps = s.steps.slice(0, maxDepth);
786
+ for (let i = 0; i < steps.length; i++) {
787
+ const from = steps[i];
788
+ const to = i + 1 < steps.length ? steps[i + 1] : (s.steps.length > maxDepth ? '…' : '✖ exit');
789
+ const k = i + '|' + from + '|' + to;
790
+ links.set(k, (links.get(k) || 0) + 1);
791
+ }
792
+ };
793
+ for (const r of rows) {
794
+ if (!cur || cur.vh !== r.vh || r.ts - cur.lastTs > GAP) { flush(cur); cur = { vh: r.vh, lastTs: r.ts, steps: [r.path] }; continue; }
795
+ cur.lastTs = r.ts;
796
+ // collapse immediate self-loops (reloads) — they add noise, not insight
797
+ if (cur.steps[cur.steps.length - 1] !== r.path) cur.steps.push(r.path);
798
+ }
799
+ flush(cur);
800
+ // keep the top 12 links per depth so the sankey stays readable
801
+ const byDepth = new Map();
802
+ for (const [k, count] of links) {
803
+ const [d, from, to] = k.split('|');
804
+ if (!byDepth.has(d)) byDepth.set(d, []);
805
+ byDepth.get(d).push({ depth: +d, from, to, count });
806
+ }
807
+ const out = [];
808
+ for (const arr of byDepth.values()) {
809
+ arr.sort((a, b) => b.count - a.count);
810
+ out.push(...arr.slice(0, 12));
811
+ }
812
+ res.json({ sessions, maxDepth, links: out });
813
+ });
814
+
815
+
816
+ app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
817
+
818
+ // ── data retention (privacy by design: minimise what we keep) ─────────────────
819
+ // When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
820
+ // Aggregates you've already exported stay yours; the raw rows simply don't linger.
821
+ const delOld = db.prepare('DELETE FROM events WHERE ts < ?');
822
+ function purgeOld() {
823
+ if (RETENTION_DAYS <= 0) return;
824
+ const cutoff = Date.now() - RETENTION_DAYS * 864e5;
825
+ const info = delOld.run(cutoff);
826
+ if (info.changes) console.log(`Tacilytics retention: purged ${info.changes} events older than ${RETENTION_DAYS}d`);
827
+ }
828
+ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).unref(); }
829
+
830
+ // ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
831
+ // Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
832
+ const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
833
+ const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
834
+ let lastDigestDay = null;
835
+ async function sendDigest() {
836
+ const yStart = new Date(new Date(Date.now() - 864e5).toISOString().slice(0, 10)).getTime();
837
+ const yEnd = yStart + 864e5, pStart = yStart - 864e5;
838
+ const lines = [];
839
+ for (const site of allSites.all()) {
840
+ const cur = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, yStart, yEnd);
841
+ if (!cur.pv) continue; // silent sites stay out of the report
842
+ const was = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, pStart, yStart);
843
+ const top = db.prepare("SELECT path, COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL GROUP BY path ORDER BY c DESC LIMIT 1").get(site.id, yStart, yEnd);
844
+ const goals = db.prepare('SELECT COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NOT NULL').get(site.id, yStart, yEnd).c;
845
+ const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
846
+ lines.push(`**${site.domain}** ${arrow} ${cur.v} visitors · ${cur.pv} views${goals ? ` · ${goals} events` : ''}${top ? ` · top \`${top.path}\`` : ''}`);
847
+ }
848
+ if (!lines.length) return;
849
+ const body = { username: 'Tacilytics', content: `📊 **Daily digest — ${new Date(yStart).toISOString().slice(0, 10)}**\n` + lines.join('\n') };
850
+ try {
851
+ const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
852
+ if (!r.ok) console.error('digest webhook failed:', r.status);
853
+ } catch (e) { console.error('digest webhook error:', e.message); }
854
+ }
855
+ if (DIGEST_WEBHOOK_URL) {
856
+ setInterval(() => {
857
+ const now = new Date();
858
+ const today = now.toISOString().slice(0, 10);
859
+ if (now.getUTCHours() === DIGEST_HOUR_UTC && lastDigestDay !== today) { lastDigestDay = today; sendDigest(); }
860
+ }, 5 * 60000).unref();
861
+ }
862
+
863
+ // ── anomaly watchdog: hourly check of the last hour vs. the same hour on previous days.
864
+ // Baseline = median of the same UTC hour over the last 14 days — same-hour compare avoids
865
+ // the classic "every night is an incident" trap of naive averages. Cooldown 6h per site+kind.
866
+ const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
867
+ const watchAlerted = new Map(); // "siteId|spike" / "siteId|drop" → last alert ts
868
+ function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? (s[s.length >> 1] + s[(s.length - 1) >> 1]) / 2 : 0; }
869
+ async function watchdogTick() {
870
+ const now = Date.now();
871
+ const hourStart = now - 3600e3;
872
+ for (const site of allSites.all()) {
873
+ const cur = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND name IS NULL').get(site.id, hourStart).v;
874
+ const hist = [];
875
+ for (let d = 1; d <= 14; d++) {
876
+ const s = hourStart - d * 864e5;
877
+ hist.push(db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, s, s + 3600e3).v);
878
+ }
879
+ const base = median(hist);
880
+ // abstain when the baseline is too thin to mean anything — a default is not a measurement
881
+ if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) continue;
882
+ let kind = null, emoji = '', text = '';
883
+ if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) { kind = 'spike'; emoji = '🚀'; text = `traffic spike: **${cur}** visitors last hour (usually ~${base})`; }
884
+ else if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) { kind = 'drop'; emoji = '📉'; text = `traffic drop: **${cur}** visitors last hour (usually ~${base}) — tracker offline?`; }
885
+ if (!kind) continue;
886
+ const key = site.id + '|' + kind;
887
+ if (now - (watchAlerted.get(key) || 0) < 6 * 3600e3) continue; // cooldown
888
+ watchAlerted.set(key, now);
889
+ try {
890
+ await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Tacilytics', content: `${emoji} **${site.domain}** ${text}` }) });
891
+ } catch (e) { console.error('watchdog webhook error:', e.message); }
892
+ }
893
+ }
894
+ if (DIGEST_WEBHOOK_URL) setInterval(watchdogTick, 3600e3).unref();
895
+
896
+ app.listen(PORT, '127.0.0.1', () => console.log(`Tacilytics on http://127.0.0.1:${PORT}${RETENTION_DAYS ? ` · retention ${RETENTION_DAYS}d` : ''}${DIGEST_WEBHOOK_URL ? ` · digest ${DIGEST_HOUR_UTC}:00 UTC` : ''}`));