tacilytics 2.1.0 → 2.2.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/public/app.js CHANGED
@@ -14,6 +14,7 @@
14
14
  }
15
15
  } catch (e) { /* corrupt value → keep both visible */ }
16
16
  var lastStats = null;
17
+ function closeModal(ov, opt) { return window.TaciModal.close(ov, opt); } // one close path for all modals (account.js)
17
18
  // dashboard segmentation filters (whitelist mirrors the server's FILTER_COLS + goal)
18
19
  var FILTER_LABELS = { country: 'Country', source: 'Source', page: 'Page', browser: 'Browser', os: 'OS', device: 'Device', lang: 'Language', medium: 'Medium', campaign: 'Campaign', platform: 'Platform', version: 'App version', goal: 'Goal' };
19
20
 
@@ -34,7 +35,16 @@
34
35
  // chart palette comes from CSS custom properties (single source of truth for the theme)
35
36
  var CSSV = getComputedStyle(document.documentElement);
36
37
  function cv(name, fb) { return (CSSV.getPropertyValue(name) || '').trim() || fb; }
37
- var COL = { v: cv('--chart-visitors', '#5b8def'), p: cv('--chart-pageviews', '#8b93a3'), red: cv('--red', '#f87171'), dim: cv('--text-dim', '#8b93a3'), line: cv('--line-strong', '#323946') };
38
+ var COL = {};
39
+ // chart colours follow the theme: re-read the CSS variables whenever it changes
40
+ function readColors() {
41
+ CSSV = getComputedStyle(document.documentElement);
42
+ COL.v = cv('--chart-visitors', '#5b8def'); COL.p = cv('--chart-pageviews', '#8b93a3'); COL.red = cv('--red', '#f87171');
43
+ COL.dim = cv('--text-dim', '#8b93a3'); COL.line = cv('--line-strong', '#323946'); COL.bg = cv('--bg', '#071114');
44
+ COL.grid = document.documentElement.getAttribute('data-theme') === 'light' ? 'rgba(10,40,38,.08)' : 'rgba(255,255,255,.05)';
45
+ }
46
+ readColors();
47
+ new MutationObserver(function () { readColors(); if (lastStats && lastStats.series) drawChart(lastStats.series); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
38
48
  // Escape any value that originates from visitor beacons before it touches innerHTML.
39
49
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
40
50
 
@@ -43,7 +53,8 @@
43
53
  opts.headers = Object.assign({ 'Content-Type': 'application/json', 'x-session-token': TOKEN }, opts.headers || {});
44
54
  return fetch(path, opts).then(function (r) {
45
55
  if (r.status === 401) { if (!SHARE) showLogin(); throw new Error('auth'); }
46
- return r.json().catch(function () { return {}; });
56
+ // same rule as account.js: JSON errors come back as {error}, anything that is not JSON throws (Tyto)
57
+ return window.TaciReadJson(r);
47
58
  });
48
59
  }
49
60
 
@@ -56,10 +67,13 @@
56
67
  var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
57
68
  return Math.max(r, g, b) < 90; // only near-black / deep navy marks; saturated reds stay
58
69
  }
70
+ // lookup tables keyed by visitor data (source, country, platform …): own keys only, so a source
71
+ // called 'constructor' or '__proto__' stays plain text instead of hitting Object.prototype
72
+ function own(o, k) { return Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined; }
59
73
  function ico(id, cls) { return '<svg class="ico ' + (cls || '') + '" aria-hidden="true"><use href="' + SPRITE + '#' + id + '"></use></svg>'; }
60
74
  function brand(name, fallbackUi) {
61
75
  if (!name) return ico(fallbackUi || 'i-other', 'ui');
62
- var c = BRAND_COLORS[name];
76
+ var c = own(BRAND_COLORS, name);
63
77
  if (name === 'samsung') c = '#6f8dff'; // Samsung navy is unreadable on dark — same hue, lighter
64
78
  if (c && isDark(c)) c = '#e7f3f1'; // black logos (Apple, X, GitHub …) would vanish on the dark UI
65
79
  return '<svg class="ico brand" aria-hidden="true"' + (c ? ' style="color:' + c + '"' : '') + '><use href="' + SPRITE + '#b-' + name + '"></use></svg>';
@@ -88,7 +102,7 @@
88
102
  var NAMES = { DE: 'Germany', AT: 'Austria', CH: 'Switzerland', GB: 'United Kingdom', US: 'United States', FR: 'France', NL: 'Netherlands', ES: 'Spain', IT: 'Italy', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland', CZ: 'Czechia', BE: 'Belgium', IE: 'Ireland', PT: 'Portugal', CA: 'Canada', AU: 'Australia', BR: 'Brazil', IN: 'India', JP: 'Japan', CN: 'China', RU: 'Russia', TR: 'Turkey', MX: 'Mexico', HU: 'Hungary', RO: 'Romania', GR: 'Greece', UA: 'Ukraine', HR: 'Croatia', NZ: 'New Zealand', SG: 'Singapore', KR: 'South Korea', IL: 'Israel', AE: 'UAE', ZA: 'South Africa' };
89
103
 
90
104
  // ── login (the form itself lives in account.js: passphrase, accounts, TOTP, passkeys) ──
91
- function showLogin() { clearInterval(window.__rt); $('login').classList.remove('hide'); $('app').classList.add('hide'); if (window.TaciAccount) window.TaciAccount.prepareLogin(); }
105
+ function showLogin() { clearInterval(window.__rt); [].forEach.call(document.querySelectorAll('.modal-overlay:not(#login):not(.hide)'), function (o) { closeModal(o, { force: true }); }); $('login').classList.remove('hide'); $('app').classList.add('hide'); var v = $('appVersion'); if (v) v.textContent = ''; if (window.TaciAccount) window.TaciAccount.prepareLogin(); }
92
106
  function hideLogin() { $('login').classList.add('hide'); $('app').classList.remove('hide'); }
93
107
  function signedIn(token) { TOKEN = token; localStorage.setItem('nyx_token', TOKEN); $('loginErr').textContent = ''; boot(); }
94
108
  $('logoutBtn').onclick = function () {
@@ -107,15 +121,48 @@
107
121
  if (!sites.length) { sel.innerHTML = '<option>no sites yet</option>'; return; }
108
122
  if (!state.site || !sites.some(function (s) { return s.domain === state.site; })) state.site = sites[0].domain;
109
123
  sel.value = state.site;
124
+ showTz();
110
125
  });
111
126
  }
112
- $('siteSelect').onchange = function () { state.site = this.value; localStorage.setItem('nyx_site', state.site); load(); if (typeof loadFunnels === 'function') loadFunnels(); };
127
+ $('siteSelect').onchange = function () { state.site = this.value; showTz(); localStorage.setItem('nyx_site', state.site); load(); if (typeof loadFunnels === 'function') loadFunnels(); };
113
128
 
114
- $('addSiteBtn').onclick = function () { $('addModal').classList.remove('hide'); $('newDomain').value = ''; $('addErr').textContent = ''; $('newDomain').focus(); };
115
- $('cancelAdd').onclick = function () { $('addModal').classList.add('hide'); };
129
+ // ── time zones: the browser's zone is the default for a new site; the list comes from Intl ──
130
+ var BROWSER_TZ = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
131
+ var TZ_LIST = (function () {
132
+ var l = []; try { l = Intl.supportedValuesOf('timeZone'); } catch (e) { /* old browser: the two below */ }
133
+ if (l.indexOf('UTC') < 0) l = ['UTC'].concat(l);
134
+ if (l.indexOf(BROWSER_TZ) < 0) l.push(BROWSER_TZ);
135
+ return l;
136
+ })();
137
+ function fillTz(sel, value) {
138
+ var list = TZ_LIST.indexOf(value) < 0 ? TZ_LIST.concat([value]) : TZ_LIST;
139
+ sel.innerHTML = list.map(function (z) { return '<option value="' + esc(z) + '">' + esc(z.replace(/_/g, ' ')) + '</option>'; }).join('');
140
+ sel.value = value;
141
+ }
142
+ // today in the site's time zone (en-CA formats as YYYY-MM-DD)
143
+ function localToday() { var s = currentSite(); try { return new Intl.DateTimeFormat('en-CA', { timeZone: (s && s.timezone) || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); } catch (e) { return new Date().toISOString().slice(0, 10); } }
144
+ function currentSite() { return (state.sites || []).filter(function (s) { return s.domain === state.site; })[0]; }
145
+ function showTz() { var s = currentSite(); $('tzName').textContent = s ? String(s.timezone || 'UTC').replace(/_/g, ' ') : 'UTC'; $('tzBtn').classList.toggle('hide', !s); }
146
+ $('tzBtn').onclick = function () {
147
+ var s = currentSite(); if (!s) return;
148
+ $('tzSite').textContent = s.domain; $('tzErr').textContent = ''; fillTz($('tzSelect'), s.timezone || 'UTC');
149
+ $('tzModal').classList.remove('hide'); $('tzSelect').focus();
150
+ };
151
+ $('cancelTz').onclick = function () { closeModal($('tzModal'), { force: true }); };
152
+ $('saveTz').onclick = function () {
153
+ var s = currentSite(); if (!s) return;
154
+ api('/api/sites/' + s.id, { method: 'PATCH', body: JSON.stringify({ timezone: $('tzSelect').value }) }).then(function (r) {
155
+ if (r.error) { $('tzErr').textContent = r.error; return; }
156
+ closeModal($('tzModal'), { force: true });
157
+ loadSites().then(load);
158
+ });
159
+ };
160
+
161
+ $('addSiteBtn').onclick = function () { $('addModal').classList.remove('hide'); $('newDomain').value = ''; fillTz($('newTz'), BROWSER_TZ); $('addErr').textContent = ''; $('newDomain').focus(); };
162
+ $('cancelAdd').onclick = function () { closeModal($('addModal'), { force: true }); };
116
163
  $('createSiteBtn').onclick = function () {
117
164
  var d = $('newDomain').value.trim();
118
- api('/api/sites', { method: 'POST', body: JSON.stringify({ domain: d }) }).then(function (s) {
165
+ api('/api/sites', { method: 'POST', body: JSON.stringify({ domain: d, timezone: $('newTz').value }) }).then(function (s) {
119
166
  if (s.error) { $('addErr').textContent = s.error; return; }
120
167
  $('addModal').classList.add('hide');
121
168
  state.site = s.domain; localStorage.setItem('nyx_site', state.site);
@@ -148,7 +195,7 @@
148
195
  return {
149
196
  s1: 'Paste this once into the <code>&lt;head&gt;</code> of your site:',
150
197
  c1: snippetFor(domain), s2: '', c2: '',
151
- hint: 'Track goals &amp; properties from your code:<br><code>tacilytics(\'Signup\')</code> · <code>tacilytics(\'Signup\', { plan: \'pro\' })</code><br><span style="opacity:.7">Privacy flags on the tag: <code>data-honor-dnt</code> · <code>data-track-outbound</code></span>'
198
+ hint: 'Track goals &amp; properties from your code:<br><code>tacilytics(\'Signup\')</code> · <code>tacilytics(\'Signup\', { plan: \'pro\' })</code><br><span style="opacity:.7">Privacy flags on the tag: <code>data-honor-dnt</code> · <code>data-track-outbound</code> · <code>data-track-downloads</code></span>'
152
199
  };
153
200
  }
154
201
  function renderSnippet() {
@@ -193,14 +240,15 @@
193
240
  };
194
241
 
195
242
  $('snippetBtn').onclick = function () { if (state.site) showSnippet(state.site); };
196
- $('closeSnippet').onclick = function () { $('snippetModal').classList.add('hide'); };
243
+ $('closeSnippet').onclick = function () { closeModal($('snippetModal')); };
197
244
  $('copySnippet').onclick = function () { copyFrom('copySnippet', 'snippetCode'); };
198
245
  $('copySnippet2').onclick = function () { copyFrom('copySnippet2', 'snippetCode2', 'Copy step 2'); };
199
246
 
200
247
  // ── live visitors (last 30 min) ──
201
248
  var liveTimer = null;
202
249
  function openLive() { if (!state.site || state.site === 'no sites yet') return; $('liveModal').classList.remove('hide'); fetchLive(); clearInterval(liveTimer); liveTimer = setInterval(fetchLive, 8000); }
203
- function closeLive() { $('liveModal').classList.add('hide'); clearInterval(liveTimer); liveTimer = null; }
250
+ function closeLive() { closeModal($('liveModal')); }
251
+ $('liveModal').addEventListener('taci:close', function () { clearInterval(liveTimer); liveTimer = null; }); // every exit stops polling
204
252
  function fetchLive() {
205
253
  if (document.hidden) return;
206
254
  api('/api/realtime?site=' + encodeURIComponent(state.site)).then(function (r) {
@@ -323,8 +371,8 @@
323
371
  el.classList.remove('hide');
324
372
  var chips = keys.map(function (k) {
325
373
  var v = state.filters[k];
326
- var disp = k === 'country' ? (flagIcon(v) + ' ' + esc(NAMES[v] || v)) : k === 'platform' ? (platformIcon(v) + ' ' + esc(platformLabel(v))) : esc(v);
327
- return '<span class="chip"><span class="k">' + esc(FILTER_LABELS[k] || k) + '</span>' + disp +
374
+ var disp = k === 'country' ? (flagIcon(v) + ' ' + esc(own(NAMES, v) || v)) : k === 'platform' ? (platformIcon(v) + ' ' + esc(platformLabel(v))) : esc(v);
375
+ return '<span class="chip"><span class="k">' + esc(own(FILTER_LABELS, k) || k) + '</span>' + disp +
328
376
  '<button data-rm="' + esc(k) + '" title="Remove" aria-label="Remove filter">' + ico('i-x', 'ui') + '</button></span>';
329
377
  }).join('');
330
378
  el.innerHTML = '<span class="flabel">Filtered by</span>' + chips + '<span class="chip clear" id="clearFilters">Clear all</span>';
@@ -360,6 +408,23 @@
360
408
  '<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.6" vector-effect="non-scaling-stroke" opacity=".9"/></svg>';
361
409
  card.insertAdjacentHTML('beforeend', svg);
362
410
  }
411
+ // ── chart annotations: a thin coral line + diamond per annotated day (daily views only) ──
412
+ function annotationsFor(d) {
413
+ var list = (lastStats && lastStats.annotations) || [];
414
+ return d && d.day ? list.filter(function (a) { return a.day === d.day; }) : [];
415
+ }
416
+ function annotationMarks(series, x, H) {
417
+ var list = (lastStats && lastStats.annotations) || [];
418
+ if (!list.length) return '';
419
+ var out = '';
420
+ series.forEach(function (d, i) {
421
+ if (!annotationsFor(d).length) return;
422
+ var cx = x(i).toFixed(1);
423
+ out += '<line x1="' + cx + '" x2="' + cx + '" y1="14" y2="' + H + '" stroke="var(--accent-2, #ff7a5c)" stroke-width="1.2" stroke-dasharray="3 4" opacity=".75" vector-effect="non-scaling-stroke"/>' +
424
+ '<path d="M' + cx + ' 3 l5 6 l-5 6 l-5 -6 z" fill="var(--accent-2, #ff7a5c)"/>';
425
+ });
426
+ return out;
427
+ }
363
428
  function drawChart(series) {
364
429
  var svg = $('chart'); var W = 800, H = 220, pad = 8;
365
430
  var showV = state.show.visitors, showP = state.show.pageviews;
@@ -381,25 +446,146 @@
381
446
  '<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">' +
382
447
  '<stop offset="0%" stop-color="' + COL.v + '" stop-opacity="0.32"/><stop offset="100%" stop-color="' + COL.v + '" stop-opacity="0"/>' +
383
448
  '</linearGradient></defs>' +
384
- [0.25, 0.5, 0.75].map(function (f) { var gy = (pad + f * (H - 2 * pad)).toFixed(1); return '<line x1="0" x2="' + W + '" y1="' + gy + '" y2="' + gy + '" stroke="rgba(255,255,255,.05)" stroke-dasharray="2 6" vector-effect="non-scaling-stroke"/>'; }).join('') +
449
+ [0.25, 0.5, 0.75].map(function (f) { var gy = (pad + f * (H - 2 * pad)).toFixed(1); return '<line x1="0" x2="' + W + '" y1="' + gy + '" y2="' + gy + '" stroke="' + COL.grid + '" stroke-dasharray="2 6" vector-effect="non-scaling-stroke"/>'; }).join('') +
385
450
  (showV ? '<path d="' + areaP + '" fill="url(#ag)"/>' : '') +
386
451
  (showP ? '<path d="' + path('pageviews') + '" fill="none" stroke="' + COL.p + '" stroke-width="1.6" stroke-dasharray="4 4" opacity="0.85"/>' : '') +
387
452
  (showV ? '<path d="' + path('visitors') + '" fill="none" stroke="' + COL.v + '" stroke-width="2.4" stroke-linecap="round"/>' : '') +
388
- (showV && n <= 40 ? series.map(function (d, i) { return '<circle cx="' + x(i).toFixed(1) + '" cy="' + y(d.visitors).toFixed(1) + '" r="2.4" fill="' + COL.v + '" stroke="#071114" stroke-width="1.5"/>'; }).join('') : '') +
453
+ (showV && n <= 40 ? series.map(function (d, i) { return '<circle cx="' + x(i).toFixed(1) + '" cy="' + y(d.visitors).toFixed(1) + '" r="2.4" fill="' + COL.v + '" stroke="' + COL.bg + '" stroke-width="1.5"/>'; }).join('') : '') +
454
+ annotationMarks(series, x, H) +
389
455
  hovers;
390
456
  var tip = $('tip');
391
457
  Array.prototype.forEach.call(svg.querySelectorAll('rect'), function (rect) {
392
458
  rect.addEventListener('mousemove', function (e) {
393
459
  var d = series[+rect.dataset.i];
460
+ var notes = annotationsFor(d);
394
461
  tip.innerHTML = '<strong>' + esc(d.label) + '</strong>' +
395
462
  (showV ? '<br>' + d.visitors + ' visitors' : '') +
396
- (showP ? '<br>' + d.pageviews + ' views' : '');
463
+ (showP ? '<br>' + d.pageviews + ' views' : '') +
464
+ notes.map(function (a) { return '<br><span class="tip-note" data-no-i18n>◆ ' + esc(a.text) + '</span>'; }).join('');
397
465
  tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 10) + 'px'; tip.style.opacity = 1;
398
466
  });
399
467
  rect.addEventListener('mouseleave', function () { tip.style.opacity = 0; });
400
468
  });
401
469
  }
402
470
 
471
+ // ── import history (Plausible / GA4) ──
472
+ var impSrc = 'plausible';
473
+ var IMP_HOWTO = {
474
+ plausible: 'In Plausible: <strong>Site settings → Imports &amp; Exports → Export data</strong>. Upload the ZIP you get (or the CSV files in it).',
475
+ ga: 'In Google Analytics 4: open a report (e.g. <strong>Pages and screens</strong>, <strong>Traffic acquisition</strong>, <strong>Demographic details</strong>), add <strong>Date</strong> as a secondary dimension, then <strong>Share → Download file → CSV</strong>. One file per report; pick several at once.',
476
+ };
477
+ function openImport() {
478
+ $('importModal').classList.remove('hide'); $('impErr').textContent = ''; $('impStatus').textContent = ''; $('impSite').textContent = state.site;
479
+ setImpSrc(impSrc); loadImports();
480
+ }
481
+ function setImpSrc(src) {
482
+ impSrc = src;
483
+ Array.prototype.forEach.call($('impTabs').children, function (b) { b.classList.toggle('active', b.dataset.src === src); });
484
+ $('impHowto').innerHTML = IMP_HOWTO[src];
485
+ }
486
+ function loadImports() {
487
+ api('/api/imports?site=' + encodeURIComponent(state.site)).then(function (r) {
488
+ if (r.error) return;
489
+ $('impList').innerHTML = r.imports.length ? '<div class="set-card"><h3>Imported</h3>' + r.imports.map(function (i) {
490
+ return '<div class="set-row"><span><strong>' + (i.source === 'ga' ? 'Google Analytics' : 'Plausible') + '</strong> ' + esc(i.first_day || '?') + ' – ' + esc(i.last_day || '?') +
491
+ ' <span class="muted small">· ' + Number(i.pageviews).toLocaleString() + ' views' + (i.created_by ? ' · by ' + esc(i.created_by) : '') + '</span></span>' +
492
+ '<button class="btn ghost danger mini" data-delimp="' + i.id + '">Remove</button></div>';
493
+ }).join('') + (r.countsUntil ? '<p class="muted small" style="margin:8px 0 0">Own data starts ' + esc(r.countsUntil) + '; imported days from then on are ignored.</p>' : '') + '</div>' : '';
494
+ $('impList').querySelectorAll('[data-delimp]').forEach(function (b) {
495
+ b.onclick = function () { if (confirm('Remove this import? The imported numbers disappear from all charts.')) api('/api/imports/' + b.dataset.delimp, { method: 'DELETE' }).then(function () { loadImports(); load(); }); };
496
+ });
497
+ });
498
+ }
499
+ $('importBtn').onclick = openImport;
500
+ $('closeImport').onclick = function () { closeModal($('importModal')); };
501
+ $('impTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) setImpSrc(b.dataset.src); };
502
+ $('impGo').onclick = function () {
503
+ var files = Array.prototype.slice.call($('impFile').files || []);
504
+ $('impErr').textContent = '';
505
+ if (!files.length) { $('impErr').textContent = 'Choose a file first'; return; }
506
+ var done = 0, views = 0;
507
+ $('impStatus').textContent = 'Uploading…';
508
+ // one request per file: each file becomes its own import (and can be removed on its own)
509
+ files.reduce(function (p, f) {
510
+ return p.then(function () {
511
+ return fetch('/api/imports?site=' + encodeURIComponent(state.site) + '&source=' + impSrc + '&name=' + encodeURIComponent(f.name), {
512
+ method: 'POST', headers: { 'x-session-token': localStorage.getItem('nyx_token') || '', 'Content-Type': /\.zip$/i.test(f.name) ? 'application/zip' : 'text/csv' }, body: f,
513
+ }).then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(f.name + ': ' + (j.error || r.status)); done++; views += j.pageviews || 0; if (j.note) $('impStatus').textContent = j.note; }); });
514
+ });
515
+ }, Promise.resolve()).then(function () {
516
+ $('impStatus').textContent = done + ' file' + (done > 1 ? 's' : '') + ' imported (' + views.toLocaleString() + ' views). ' + ($('impStatus').textContent.indexOf('Days from') === 0 ? $('impStatus').textContent : '');
517
+ $('impFile').value = ''; loadImports(); load();
518
+ }).catch(function (e) { $('impErr').textContent = e.message; $('impStatus').textContent = ''; loadImports(); load(); });
519
+ };
520
+
521
+ // ── alerts ──
522
+ var KIND_LABEL = { spike: 'Traffic spike', drop: 'Traffic drop', goal: 'Goal' };
523
+ function openAlerts() {
524
+ // a fresh form only when the modal opens — openAlerts also re-renders the list while it is open
525
+ if ($('alertsModal').classList.contains('hide')) { $('alKind').value = 'spike'; $('alGoalRow').classList.add('hide'); $('alGoal').value = ''; $('alThreshold').value = '10'; $('alWindow').value = '24'; $('alWebhook').value = ''; }
526
+ $('alertsModal').classList.remove('hide'); $('alertsErr').textContent = ''; $('alertsSite').textContent = state.site;
527
+ var goals = ((lastStats && lastStats.goals) || []).map(function (g) { return g.name; });
528
+ $('alGoalList').innerHTML = goals.map(function (g) { return '<option value="' + esc(g) + '">'; }).join('');
529
+ api('/api/alerts?site=' + encodeURIComponent(state.site)).then(function (r) {
530
+ if (r.error) { $('alertsErr').textContent = r.error; return; }
531
+ $('alHint').textContent = r.fallbackWebhook ? 'Leave the webhook empty to use the server default (DIGEST_WEBHOOK_URL).' : 'Discord: channel settings → Integrations → Webhooks → Copy URL.';
532
+ $('alWebhook').placeholder = r.fallbackWebhook ? 'Webhook URL (optional — server default)' : 'Webhook URL (https://…)';
533
+ $('alertsList').innerHTML = r.alerts.length ? '<div class="set-card">' + r.alerts.map(function (a) {
534
+ var what = a.kind === 'goal' ? a.threshold + '× <span data-no-i18n>' + esc(a.goal) + '</span> in ' + (a.window_hours === 1 ? '1 h' : a.window_hours === 24 ? '24 h' : '7 days') : KIND_LABEL[a.kind];
535
+ return '<div class="set-row"><span>' + ico(a.kind === 'goal' ? 'i-goal' : a.kind === 'drop' ? 'i-down' : 'i-up', 'ui') + '<strong>' + what + '</strong>' +
536
+ ' <span class="muted small">→ ' + (a.webhook ? '<span data-no-i18n>' + esc(a.webhook) + '</span>' : 'server default') + (a.last_fired ? ' · last sent ' + new Date(a.last_fired).toLocaleString() : '') + '</span></span>' +
537
+ '<label class="small muted" style="display:inline-flex;gap:5px;align-items:center"><input type="checkbox" data-al-on="' + a.id + '"' + (a.enabled ? ' checked' : '') + ' style="width:auto;margin:0"> on</label>' +
538
+ '<button class="btn ghost mini" data-al-test="' + a.id + '">Test</button>' +
539
+ '<button class="btn ghost danger mini" data-al-del="' + a.id + '">Remove</button></div>';
540
+ }).join('') + '</div>' : '<p class="muted small">No alerts for this site yet.</p>';
541
+ var L = $('alertsList');
542
+ L.querySelectorAll('[data-al-on]').forEach(function (c) { c.onchange = function () { api('/api/alerts/' + c.dataset.alOn, { method: 'PATCH', body: JSON.stringify({ enabled: c.checked }) }); }; });
543
+ L.querySelectorAll('[data-al-del]').forEach(function (b) { b.onclick = function () { api('/api/alerts/' + b.dataset.alDel, { method: 'DELETE' }).then(openAlerts); }; });
544
+ L.querySelectorAll('[data-al-test]').forEach(function (b) {
545
+ b.onclick = function () {
546
+ b.textContent = 'Sending…';
547
+ api('/api/alerts/' + b.dataset.alTest + '/test', { method: 'POST' }).then(function (r) { b.textContent = r.error ? 'Failed' : 'Sent ✓'; if (r.error) $('alertsErr').textContent = r.error; });
548
+ };
549
+ });
550
+ });
551
+ }
552
+ $('alertsBtn').onclick = openAlerts;
553
+ $('closeAlerts').onclick = function () { closeModal($('alertsModal'), { force: true }); };
554
+ $('alKind').onchange = function () { $('alGoalRow').classList.toggle('hide', this.value !== 'goal'); };
555
+ $('alAdd').onclick = function () {
556
+ $('alertsErr').textContent = '';
557
+ var body = { site: state.site, kind: $('alKind').value, webhook: $('alWebhook').value.trim() || undefined };
558
+ if (body.kind === 'goal') { body.goal = $('alGoal').value.trim(); body.threshold = Number($('alThreshold').value); body.window_hours = Number($('alWindow').value); }
559
+ api('/api/alerts', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
560
+ if (r.error) { $('alertsErr').textContent = r.error; return; }
561
+ $('alWebhook').value = ''; window.TaciModal.snapshot($('alertsModal')); openAlerts();
562
+ });
563
+ };
564
+
565
+ // ── add / list annotations ──
566
+ function openAnnotations() {
567
+ $('annModal').classList.remove('hide'); $('annErr').textContent = '';
568
+ $('annDay').value = localToday(); $('annText').value = ''; $('annText').focus(); window.TaciModal.snapshot($('annModal')); // also when re-opened after a save
569
+ api('/api/annotations?site=' + encodeURIComponent(state.site)).then(function (list) {
570
+ $('annList').innerHTML = (list || []).length ? list.map(function (a) {
571
+ return '<div class="set-row"><span><strong>' + esc(a.day) + '</strong> <span data-no-i18n>' + esc(a.text) + '</span>' + (a.created_by ? ' <span class="muted small">· ' + esc(a.created_by) + '</span>' : '') + '</span>' +
572
+ '<button class="btn ghost danger mini" data-delann="' + a.id + '">Remove</button></div>';
573
+ }).join('') : '<p class="muted small">No notes yet.</p>';
574
+ $('annList').querySelectorAll('[data-delann]').forEach(function (b) {
575
+ b.onclick = function () { api('/api/annotations/' + b.dataset.delann, { method: 'DELETE' }).then(function () { openAnnotations(); load(); }); };
576
+ });
577
+ });
578
+ }
579
+ $('annBtn').onclick = openAnnotations;
580
+ $('closeAnn').onclick = function () { closeModal($('annModal'), { force: true }); };
581
+ $('saveAnn').onclick = function () {
582
+ api('/api/annotations', { method: 'POST', body: JSON.stringify({ site: state.site, day: $('annDay').value, text: $('annText').value }) }).then(function (r) {
583
+ if (r.error) { $('annErr').textContent = r.error; return; }
584
+ openAnnotations(); load();
585
+ });
586
+ };
587
+ $('annText').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('saveAnn').click(); });
588
+
403
589
  // ── tab-aware panels (Pages: Top/Entry/Exit · Sources: Source/Medium/Campaign) ──
404
590
  function renderPagesPanel() {
405
591
  var s = lastStats; if (!s) return;
@@ -413,8 +599,8 @@
413
599
  'android-app://com.google.android.googlequicksearchbox': 'Google App (Android)', 't.co': 'X / Twitter',
414
600
  };
415
601
  var PLATFORM_NAMES = { web: 'Web', android: 'Android app', ios: 'iOS app', macos: 'macOS app', windows: 'Windows app', linux: 'Linux app' };
416
- function platformIcon(n) { return n === 'web' ? ico('i-web', 'ui') : n === 'windows' ? ico('i-desktop', 'ui') : brand(PLATFORM_ICON[n], 'i-mobile'); }
417
- function platformLabel(n) { return PLATFORM_NAMES[n] || n; }
602
+ function platformIcon(n) { return n === 'web' ? ico('i-web', 'ui') : n === 'windows' ? ico('i-desktop', 'ui') : brand(own(PLATFORM_ICON, n), 'i-mobile'); }
603
+ function platformLabel(n) { return own(PLATFORM_NAMES, n) || n; }
418
604
  state.pfTab = 'platforms';
419
605
  function renderPlatformsPanel() {
420
606
  var s = lastStats; if (!s) return;
@@ -426,7 +612,7 @@
426
612
  if (state.pfTab === 'appVersions') bars('p-platforms', s.appVersions || [], { filterKey: 'version', icon: function () { return ico('i-layers', 'ui'); }, label: function (r) { return 'v' + r.name; } });
427
613
  else bars('p-platforms', s.platforms || [], { filterKey: 'platform', icon: function (r) { return platformIcon(r.name); }, label: function (r) { return platformLabel(r.name); } });
428
614
  }
429
- function sourceLabel(n) { if (n === 'Direct') return 'Direct / none'; var nice = SOURCE_NAMES[n]; return nice ? nice + ' · ' + n : n; }
615
+ function sourceLabel(n) { if (n === 'Direct') return 'Direct / none'; var nice = own(SOURCE_NAMES, n); return nice ? nice + ' · ' + n : n; }
430
616
  function renderSourcesPanel() {
431
617
  var s = lastStats; if (!s) return;
432
618
  var tab = state.srcTab, fk = tab === 'mediums' ? 'medium' : tab === 'campaigns' ? 'campaign' : 'source';
@@ -538,6 +724,10 @@
538
724
  api('/api/stats?' + statsQuery()).then(function (s) {
539
725
  if (s.error) return;
540
726
  lastStats = s;
727
+ var imn = $('importedNote');
728
+ if (s.imported && s.imported.included) { imn.textContent = 'Includes imported history' + (s.imported.until ? ' (until ' + s.imported.until + ')' : '') + '.'; imn.title = 'Visitors on imported days are summed per day, not unique across days.'; imn.classList.remove('hide'); }
729
+ else if (s.imported && s.imported.excludedByFilter) { imn.textContent = 'Imported history is hidden while a filter is active.'; imn.classList.remove('hide'); }
730
+ else imn.classList.add('hide');
541
731
  $('s-visitors').textContent = fmt(s.totals.visitors);
542
732
  $('s-pageviews').textContent = fmt(s.totals.pageviews);
543
733
  $('s-bounce').textContent = s.totals.bounce + '%';
@@ -551,9 +741,9 @@
551
741
  sparkline('s-pageviews', (s.series || []).map(function (d) { return d.pageviews; }), COL.p);
552
742
  renderPagesPanel();
553
743
  renderSourcesPanel();
554
- bars('p-countries', s.countries, { filterKey: 'country', icon: function (r) { return flagIcon(r.name); }, label: function (r) { return NAMES[r.name] || r.name; } });
555
- bars('p-browsers', s.browsers, { filterKey: 'browser', icon: function (r) { return r.name === 'App' ? ico('i-mobile', 'ui') : r.name === 'Edge' ? ico('i-browser', 'ui') : brand(BROWSER_ICON[r.name], 'i-browser'); } });
556
- bars('p-os', s.os, { filterKey: 'os', icon: function (r) { return r.name === 'Windows' ? ico('i-desktop', 'ui') : brand(OS_ICON[r.name], 'i-os'); } });
744
+ bars('p-countries', s.countries, { filterKey: 'country', icon: function (r) { return flagIcon(r.name); }, label: function (r) { return own(NAMES, r.name) || r.name; } });
745
+ bars('p-browsers', s.browsers, { filterKey: 'browser', icon: function (r) { return r.name === 'App' ? ico('i-mobile', 'ui') : r.name === 'Edge' ? ico('i-browser', 'ui') : brand(own(BROWSER_ICON, r.name), 'i-browser'); } });
746
+ bars('p-os', s.os, { filterKey: 'os', icon: function (r) { return r.name === 'Windows' ? ico('i-desktop', 'ui') : brand(own(OS_ICON, r.name), 'i-os'); } });
557
747
  bars('p-devices', s.devices, { filterKey: 'device', icon: function (r) { return ico(r.name === 'Desktop' ? 'i-desktop' : r.name === 'Tablet' ? 'i-tablet' : 'i-mobile', 'ui'); } });
558
748
  renderPlatformsPanel();
559
749
  bars('p-languages', s.languages, { filterKey: 'lang' });
@@ -689,7 +879,7 @@
689
879
  };
690
880
  $('copyShare').onclick = function () { navigator.clipboard.writeText($('shareUrl').textContent); $('copyShare').textContent = 'Copied'; setTimeout(function () { $('copyShare').textContent = 'Copy link'; }, 1500); };
691
881
  $('revokeShare').onclick = function () { if (shareToken) api('/api/shares/' + shareToken, { method: 'DELETE' }).then(function () { $('shareModal').classList.add('hide'); }); };
692
- $('closeShare').onclick = function () { $('shareModal').classList.add('hide'); };
882
+ $('closeShare').onclick = function () { closeModal($('shareModal')); };
693
883
 
694
884
  // ── funnels ──
695
885
  function fmtPct(p) { return (p == null ? '—' : p + '%'); }
@@ -737,7 +927,7 @@
737
927
  $('funnelModal').classList.remove('hide'); $('funnelName').focus();
738
928
  };
739
929
  $('addStepBtn').onclick = function () { if ($('funnelSteps').children.length < 8) $('funnelSteps').appendChild(stepRow()); };
740
- $('cancelFunnel').onclick = function () { $('funnelModal').classList.add('hide'); };
930
+ $('cancelFunnel').onclick = function () { closeModal($('funnelModal'), { force: true }); };
741
931
  $('createFunnelBtn').onclick = function () {
742
932
  var steps = Array.prototype.map.call($('funnelSteps').children, function (row) {
743
933
  return { type: row.querySelector('.step-type').value, value: row.querySelector('.step-value').value.trim() };
@@ -770,7 +960,7 @@
770
960
  state.site = info.domain;
771
961
  var sel = $('siteSelect');
772
962
  sel.innerHTML = '<option>' + esc(info.domain) + '</option>'; sel.disabled = true;
773
- ['addSiteBtn', 'logoutBtn', 'settingsBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
963
+ ['addSiteBtn', 'tzBtn', 'logoutBtn', 'settingsBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn', 'annBtn', 'alertsBtn', 'importBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
774
964
  syncLegend();
775
965
  load();
776
966
  clearInterval(window.__rt);
@@ -781,5 +971,6 @@
781
971
  var INVITE = (location.pathname.match(/^\/invite\/([a-f0-9]{32,64})$/) || [])[1] || '';
782
972
  if (SHARE) bootShare();
783
973
  else if (INVITE) { $('login').classList.add('hide'); window.TaciAccount.showInvite(INVITE); }
974
+ else if (/^\/reset\/[a-f0-9]{32,64}$/.test(location.pathname)) { $('login').classList.add('hide'); window.TaciAccount.showReset(location.pathname.split('/').pop()); }
784
975
  else if (TOKEN) boot(); else showLogin();
785
976
  })();
@@ -202,4 +202,5 @@
202
202
  <symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></symbol>
203
203
  <symbol id="i-user-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" x2="19" y1="8" y2="14" /><line x1="22" x2="16" y1="11" y2="11" /></symbol>
204
204
  <symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2" /><path d="M12 18h.01" /></symbol>
205
+ <symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
205
206
  </svg>