tacilytics 2.0.2 → 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/.env.example +12 -1
- package/README.md +96 -3
- package/auth.js +788 -0
- package/cli.js +36 -0
- package/importers.js +223 -0
- package/package.json +10 -2
- package/public/account.js +570 -0
- package/public/app.js +229 -41
- package/public/assets/icons.svg +6 -0
- package/public/i18n.js +242 -0
- package/public/index.html +194 -37
- package/public/style.css +140 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +572 -110
- package/tz.js +96 -0
- package/webhook.js +109 -0
package/report.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Weekly report: numbers + e-mail (HTML and plain text), in English or German.
|
|
3
|
+
// Pure functions over the database; sending/scheduling stays in server.js.
|
|
4
|
+
//
|
|
5
|
+
// One block per site the person can see, covering the last 7 full LOCAL days of the site's time zone
|
|
6
|
+
// (sites.timezone, DST days 23/25 h) compared with
|
|
7
|
+
// the 7 before: key figures, a day-by-day chart, highlights, pages, sources, countries,
|
|
8
|
+
// devices, goals, shop numbers and the notes of that week. HTML is table-based with inline
|
|
9
|
+
// styles only (mail clients drop <style> and flexbox), light design, max 600 px wide.
|
|
10
|
+
|
|
11
|
+
const TZ = require('./tz');
|
|
12
|
+
const SOURCE_NAMES = {
|
|
13
|
+
'com.google.android.googlequicksearchbox': 'Google App (Android)', 'com.google.android.gm': 'Gmail App',
|
|
14
|
+
'fb': 'Facebook', 'm.facebook.com': 'Facebook (mobile)', 'l.facebook.com': 'Facebook', 'lm.facebook.com': 'Facebook',
|
|
15
|
+
'ig': 'Instagram', 'l.instagram.com': 'Instagram', 'homescreen': 'Homescreen (app shortcut)',
|
|
16
|
+
'android-app://com.google.android.googlequicksearchbox': 'Google App (Android)', 't.co': 'X / Twitter',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TXT = {
|
|
20
|
+
en: {
|
|
21
|
+
weekly: 'Weekly report', compared: 'compared with the week before', hi: 'Hi', intro: 'here is how last week went.',
|
|
22
|
+
allSites: 'All sites together', sitesN: 'Websites', visitors: 'Visitors', pageviews: 'Pageviews', bounce: 'Bounce rate', time: 'Avg. visit time',
|
|
23
|
+
vpv: 'Views / visitor', days: 'Day by day', highlights: 'Highlights', pages: 'Top pages', sources: 'Top sources',
|
|
24
|
+
countries: 'Countries', devices: 'Devices', goals: 'Goals', shop: 'Shop', orders: 'Orders', revenue: 'Revenue', aov: 'Avg. order',
|
|
25
|
+
cr: 'Conversion rate', notes: 'Notes this week', open: 'Open the dashboard', bestDay: 'Best day', climber: 'Climbing',
|
|
26
|
+
newSource: 'New source', newGoal: 'First time', more: 'more visitors than the week before', fewer: 'fewer visitors than the week before',
|
|
27
|
+
same: 'as many visitors as the week before', direct: 'Direct / none', unknown: 'Unknown', desktop: 'Desktop', mobile: 'Mobile', tablet: 'Tablet',
|
|
28
|
+
quiet: 'A quiet week — no visitors.', compactNote: 'Smaller sites are shown briefly to keep this e-mail readable in every mail app — the dashboard has all details.', footer: "Cookieless analytics — we count, we don't tell.",
|
|
29
|
+
off: 'You get this because you turned it on. Turn it off under Settings → Security → Weekly report.',
|
|
30
|
+
subjOne: (d, v, p) => `${d}: ${v} visitors last week (${p})`, subjMany: (n, v, p) => `${n} sites: ${v} visitors last week (${p})`,
|
|
31
|
+
new: 'new', noPrev: 'no data the week before', vs: 'vs.', min: 'min', s: 's', pp: 'pp',
|
|
32
|
+
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], locale: 'en-GB',
|
|
33
|
+
},
|
|
34
|
+
de: {
|
|
35
|
+
weekly: 'Wochenbericht', compared: 'im Vergleich zur Vorwoche', hi: 'Hallo', intro: 'so lief die letzte Woche.',
|
|
36
|
+
allSites: 'Alle Seiten zusammen', sitesN: 'Websites', visitors: 'Besucher', pageviews: 'Seitenaufrufe', bounce: 'Absprungrate', time: 'Ø Besuchsdauer',
|
|
37
|
+
vpv: 'Aufrufe / Besucher', days: 'Tag für Tag', highlights: 'Auffällig', pages: 'Top-Seiten', sources: 'Top-Quellen',
|
|
38
|
+
countries: 'Länder', devices: 'Geräte', goals: 'Ziele', shop: 'Shop', orders: 'Bestellungen', revenue: 'Umsatz', aov: 'Ø Bestellwert',
|
|
39
|
+
cr: 'Conversion-Rate', notes: 'Notizen dieser Woche', open: 'Dashboard öffnen', bestDay: 'Bester Tag', climber: 'Im Aufwind',
|
|
40
|
+
newSource: 'Neue Quelle', newGoal: 'Zum ersten Mal', more: 'mehr Besucher als in der Vorwoche', fewer: 'weniger Besucher als in der Vorwoche',
|
|
41
|
+
same: 'genauso viele Besucher wie in der Vorwoche', direct: 'Direkt / keine', unknown: 'Unbekannt', desktop: 'Desktop', mobile: 'Mobil', tablet: 'Tablet',
|
|
42
|
+
quiet: 'Eine ruhige Woche, keine Besucher.', compactNote: 'Kleinere Seiten stehen hier nur kurz, damit die Mail in jedem Mailprogramm vollständig angezeigt wird. Alle Details im Dashboard.', footer: 'Analyse ohne Cookies · wir zählen, wir verraten nichts.',
|
|
43
|
+
off: 'Du bekommst diese Mail, weil du sie eingeschaltet hast. Abschalten unter Einstellungen → Sicherheit → Wochenbericht.',
|
|
44
|
+
subjOne: (d, v, p) => `${d}: ${v} Besucher letzte Woche (${p})`, subjMany: (n, v, p) => `${n} Seiten: ${v} Besucher letzte Woche (${p})`,
|
|
45
|
+
new: 'neu', noPrev: 'keine Daten in der Vorwoche', vs: 'ggü.', min: 'Min.', s: 's', pp: 'Pp.',
|
|
46
|
+
weekdays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], locale: 'de-DE',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const C = { bg: '#eef4f3', card: '#ffffff', line: '#dbe7e5', text: '#0f2624', dim: '#4b6662', faint: '#7c918e', teal: '#0f9e8e', tealSoft: '#d5f0ec', up: '#0f8a5f', down: '#c2410c', coral: '#ff7a5c' };
|
|
51
|
+
const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
52
|
+
const esc = (x) => String(x == null ? '' : x).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
53
|
+
const flag = (cc) => (/^[A-Z]{2}$/.test(cc || '') ? String.fromCodePoint(...[...cc].map((c) => 0x1f1a5 + c.charCodeAt(0))) : '🌐');
|
|
54
|
+
|
|
55
|
+
function createReport(db, { paymentHosts = [] } = {}) {
|
|
56
|
+
const NOT_INTERNAL = '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)';
|
|
57
|
+
const PV = `site_id = ? AND ts >= ? AND ts < ? AND name IS NULL AND ${NOT_INTERNAL}`;
|
|
58
|
+
const EV = `site_id = ? AND ts >= ? AND ts < ? AND name IS NOT NULL AND ${NOT_INTERNAL}`;
|
|
59
|
+
const q = {
|
|
60
|
+
totals: db.prepare(`SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${PV}`),
|
|
61
|
+
bounced: db.prepare(`SELECT COUNT(*) n FROM (SELECT vh FROM events WHERE ${PV} GROUP BY vh HAVING COUNT(*) = 1)`),
|
|
62
|
+
dur: db.prepare(`SELECT AVG(dur) d FROM events WHERE ${PV} AND dur IS NOT NULL`),
|
|
63
|
+
pages: db.prepare(`SELECT path k, COUNT(DISTINCT vh) v, COUNT(*) pv FROM events WHERE ${PV} GROUP BY path ORDER BY v DESC, pv DESC LIMIT 50`),
|
|
64
|
+
sources: db.prepare(`SELECT COALESCE(NULLIF(source, ''), 'Direct') k, COUNT(DISTINCT vh) v FROM events WHERE ${PV} GROUP BY k ORDER BY v DESC LIMIT 50`),
|
|
65
|
+
countries: db.prepare(`SELECT COALESCE(NULLIF(country, ''), '?') k, COUNT(DISTINCT vh) v FROM events WHERE ${PV} GROUP BY k ORDER BY v DESC LIMIT 6`),
|
|
66
|
+
devices: db.prepare(`SELECT COALESCE(NULLIF(device, ''), '?') k, COUNT(DISTINCT vh) v FROM events WHERE ${PV} GROUP BY k ORDER BY v DESC`),
|
|
67
|
+
goals: db.prepare(`SELECT name k, COUNT(*) c, COUNT(DISTINCT vh) v FROM events WHERE ${EV} GROUP BY name ORDER BY c DESC LIMIT 50`),
|
|
68
|
+
purchases: db.prepare(`SELECT COUNT(*) n, SUM(CAST(json_extract(props, '$.value') AS REAL)) r, COUNT(DISTINCT vh || day) buyers FROM events WHERE ${EV.replace('name IS NOT NULL', "name = 'Purchase'")}`),
|
|
69
|
+
notes: db.prepare('SELECT day, text FROM annotations WHERE site_id = ? AND day >= ? AND day <= ? ORDER BY day, id'),
|
|
70
|
+
};
|
|
71
|
+
void paymentHosts;
|
|
72
|
+
|
|
73
|
+
function windowStats(sid, a, b) {
|
|
74
|
+
const t = q.totals.get(sid, a, b);
|
|
75
|
+
const bounced = t.v ? q.bounced.get(sid, a, b).n : 0;
|
|
76
|
+
const d = q.dur.get(sid, a, b).d;
|
|
77
|
+
return { v: t.v, pv: t.pv, bounce: t.v ? Math.round((bounced / t.v) * 100) : null, dur: d == null ? null : Math.round(d), vpv: t.v ? +(t.pv / t.v).toFixed(1) : null };
|
|
78
|
+
}
|
|
79
|
+
const toMap = (rows, f = 'v') => new Map(rows.map((r) => [r.k, r[f]]));
|
|
80
|
+
|
|
81
|
+
// the local day is computed in SQL from ts; offsets come from tz.js as inlined integers only
|
|
82
|
+
const dayRowsFor = (sid, a, b, tz) => db.prepare(`SELECT ${TZ.daySql(tz, a, b)} day, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${PV} GROUP BY 1`).all(sid, a, b);
|
|
83
|
+
function siteWeek(sid, now, tz) {
|
|
84
|
+
tz = TZ.validTz(tz) || 'UTC';
|
|
85
|
+
const today = TZ.localDay(tz, now);
|
|
86
|
+
const end = TZ.dayStart(tz, today);
|
|
87
|
+
const fromDay = TZ.addDays(today, -7), prevDay = TZ.addDays(today, -14);
|
|
88
|
+
const a = TZ.dayStart(tz, fromDay), p = TZ.dayStart(tz, prevDay);
|
|
89
|
+
const cur = windowStats(sid, a, end), prev = windowStats(sid, p, a);
|
|
90
|
+
const dayRows = new Map(dayRowsFor(sid, a, end, tz).map((r) => [r.day, r]));
|
|
91
|
+
const prevDayRows = new Map(dayRowsFor(sid, p, a, tz).map((r) => [r.day, r]));
|
|
92
|
+
const days = [];
|
|
93
|
+
for (let i = 0; i < 7; i++) {
|
|
94
|
+
const k = TZ.addDays(fromDay, i), pk = TZ.addDays(prevDay, i);
|
|
95
|
+
days.push({ day: k, dow: TZ.weekday(k), v: (dayRows.get(k) || {}).v || 0, pv: (dayRows.get(k) || {}).pv || 0, prevV: (prevDayRows.get(pk) || {}).v || 0 });
|
|
96
|
+
}
|
|
97
|
+
const pagesPrev = toMap(q.pages.all(sid, p, a));
|
|
98
|
+
const pages = q.pages.all(sid, a, end).map((r) => ({ ...r, prev: pagesPrev.get(r.k) || 0 }));
|
|
99
|
+
const srcPrev = toMap(q.sources.all(sid, p, a));
|
|
100
|
+
const sources = q.sources.all(sid, a, end).map((r) => ({ ...r, prev: srcPrev.get(r.k) || 0 }));
|
|
101
|
+
const goalsPrev = toMap(q.goals.all(sid, p, a), 'c');
|
|
102
|
+
const goals = q.goals.all(sid, a, end).filter((g) => g.k !== 'Purchase').map((r) => ({ ...r, prev: goalsPrev.get(r.k) || 0 }));
|
|
103
|
+
const pc = q.purchases.get(sid, a, end), pp = q.purchases.get(sid, p, a);
|
|
104
|
+
const shop = pc.n ? { orders: pc.n, prevOrders: pp.n, revenue: +(pc.r || 0).toFixed(2), prevRevenue: +(pp.r || 0).toFixed(2), cr: cur.v ? +((pc.buyers / cur.v) * 100).toFixed(2) : 0 } : null;
|
|
105
|
+
// highlights: only when they say something (thresholds keep noise out of small sites)
|
|
106
|
+
const best = days.reduce((m, d) => (d.v > m.v ? d : m), days[0]);
|
|
107
|
+
const climber = pages.filter((x) => x.v >= 5).map((x) => ({ ...x, gain: x.v - x.prev })).sort((x, y) => y.gain - x.gain)[0];
|
|
108
|
+
const newSource = sources.find((x) => x.prev === 0 && x.v >= 3 && x.k !== 'Direct');
|
|
109
|
+
const newGoal = goals.find((x) => x.prev === 0 && x.c >= 1);
|
|
110
|
+
return {
|
|
111
|
+
from: fromDay, to: TZ.addDays(today, -1), timezone: tz,
|
|
112
|
+
cur, prev, days, pages: pages.slice(0, 5), sources: sources.slice(0, 5),
|
|
113
|
+
countries: q.countries.all(sid, a, end), devices: q.devices.all(sid, a, end), goals: goals.slice(0, 5), shop,
|
|
114
|
+
notes: q.notes.all(sid, fromDay, TZ.addDays(today, -1)),
|
|
115
|
+
highlights: {
|
|
116
|
+
best: best && best.v ? best : null,
|
|
117
|
+
climber: climber && climber.gain >= Math.max(3, Math.round(climber.prev * 0.2)) ? climber : null,
|
|
118
|
+
newSource: newSource || null, newGoal: newGoal || null,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function build(user, sites, now, { publicUrl = '', lang = 'en' } = {}) {
|
|
124
|
+
if (!Object.hasOwn(TXT, lang)) lang = 'en'; // goes into <html lang> — valid no matter who calls
|
|
125
|
+
const L = TXT[lang];
|
|
126
|
+
const nf = new Intl.NumberFormat(L.locale);
|
|
127
|
+
const money = new Intl.NumberFormat(L.locale, { style: 'currency', currency: 'EUR' });
|
|
128
|
+
const N = (x) => nf.format(x || 0);
|
|
129
|
+
const regions = (() => { try { return new Intl.DisplayNames([L.locale], { type: 'region' }); } catch { return null; } })();
|
|
130
|
+
const country = (cc) => (cc === '?' ? L.unknown : (regions && /^[A-Z]{2}$/.test(cc) ? regions.of(cc) : cc));
|
|
131
|
+
// own keys only: the source is visitor-controlled (referrer/utm) — 'constructor' must stay a string
|
|
132
|
+
const srcName = (x) => (x === 'Direct' ? L.direct : Object.hasOwn(SOURCE_NAMES, x) ? SOURCE_NAMES[x] : x);
|
|
133
|
+
const devName = (x) => ({ Desktop: L.desktop, Mobile: L.mobile, Tablet: L.tablet }[x] || L.unknown);
|
|
134
|
+
const dateFmt = (iso, o) => new Date(iso + 'T12:00:00Z').toLocaleDateString(L.locale, { timeZone: 'UTC', ...o });
|
|
135
|
+
const pct = (a, b) => (!b ? (a ? L.new : '±0 %') : (a >= b ? '+' : '−') + Math.abs(Math.round(((a - b) / b) * 100)) + ' %');
|
|
136
|
+
const dur = (s) => (s == null ? '–' : s >= 60 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')} ${L.min}` : `${s} ${L.s}`);
|
|
137
|
+
const shortPath = (p) => (p.length > 58 ? p.slice(0, 55) + '…' : p);
|
|
138
|
+
|
|
139
|
+
const blocks = sites.map((st) => ({ st, n: siteWeek(st.id, now, st.timezone) })).filter((b) => b.n.cur.pv || b.n.prev.pv);
|
|
140
|
+
if (!blocks.length) return null;
|
|
141
|
+
const n0 = blocks[0].n;
|
|
142
|
+
const range = `${dateFmt(n0.from, { day: 'numeric', month: 'short' })} – ${dateFmt(n0.to, { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
|
143
|
+
const sumV = blocks.reduce((s, b) => s + b.n.cur.v, 0), sumPrevV = blocks.reduce((s, b) => s + b.n.prev.v, 0);
|
|
144
|
+
const sumPv = blocks.reduce((s, b) => s + b.n.cur.pv, 0), sumPrevPv = blocks.reduce((s, b) => s + b.n.prev.pv, 0);
|
|
145
|
+
const name = user.username || '';
|
|
146
|
+
|
|
147
|
+
// ── small HTML helpers ──
|
|
148
|
+
// below 10 the change is shown absolute (+10) — +1000 % on 1 → 11 is noise
|
|
149
|
+
const delta = (a, b, { invert = false, points = false } = {}) => {
|
|
150
|
+
if (a == null || b == null) return '';
|
|
151
|
+
let txt, good;
|
|
152
|
+
if (points) { const d = a - b; if (!d) return `<span style="color:${C.faint}">±0 ${L.pp}</span>`; txt = (d > 0 ? '+' : '−') + Math.abs(d) + ' ' + L.pp; good = invert ? d < 0 : d > 0; }
|
|
153
|
+
else { if (!b && !a) return ''; if (a === b) return `<span style="color:${C.faint}">±0</span>`; txt = b < 10 && a !== b ? (a > b ? '+' : '−') + N(Math.abs(a - b)) : pct(a, b); if (a === b) return `<span style="color:${C.faint}">${txt}</span>`; good = invert ? a < b : a > b; if (!b) good = !invert; }
|
|
154
|
+
return `<span style="color:${good ? C.up : C.down};font-weight:600">${good === !invert ? '▲' : '▼'} ${esc(txt)}</span>`;
|
|
155
|
+
};
|
|
156
|
+
const h3 = (t) => `<tr><td style="padding:22px 0 8px;font-weight:700;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:${C.faint}">${esc(t)}</td></tr>`;
|
|
157
|
+
const kpi = (label, value, d) => `<td width="33%" valign="top" style="padding:10px 8px 10px 0">
|
|
158
|
+
<div style="font-size:12px;color:${C.dim};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(label)}</div>
|
|
159
|
+
<div style="font-weight:700;font-size:22px;color:${C.text};margin:2px 0;white-space:nowrap">${value}</div>
|
|
160
|
+
<div style="font-size:12px">${d || ' '}</div></td>`;
|
|
161
|
+
const barRows = (rows, label, { max = Math.max(1, ...rows.map((r) => r.v)), sub } = {}) => `<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0">${rows.map((r) =>
|
|
162
|
+
`<tr><td style="font-size:13px;padding:5px 8px 2px 0">${label(r)}</td><td align="right" style="font-weight:600;font-size:13px;white-space:nowrap;padding-top:5px">${N(r.v)}${sub ? ` <span style="font-weight:400">${sub(r)}</span>` : ''}</td></tr>` +
|
|
163
|
+
`<tr><td colspan="2"><div style="background:${C.tealSoft};border-radius:3px;height:6px"><div style="width:${Math.max(2, Math.round((r.v / max) * 100))}%;background:${C.teal};border-radius:3px;height:6px;font-size:0;line-height:0"> </div></div></td></tr>`).join('')}</table></td></tr>`;
|
|
164
|
+
const chart = (days) => {
|
|
165
|
+
const max = Math.max(1, ...days.map((d) => Math.max(d.v, d.prevV)));
|
|
166
|
+
const best = days.reduce((m, d) => (d.v > m.v ? d : m), days[0]);
|
|
167
|
+
const col = (d) => {
|
|
168
|
+
const h = Math.max(3, Math.round((d.v / max) * 90)), hp = Math.max(2, Math.round((d.prevV / max) * 90));
|
|
169
|
+
return `<td align="center" valign="bottom" style="padding:0 3px">
|
|
170
|
+
<div style="font-weight:600;font-size:11px;color:${C.text};padding-bottom:3px">${N(d.v)}</div>
|
|
171
|
+
<table role="presentation" cellpadding="0" cellspacing="0" align="center"><tr>
|
|
172
|
+
<td valign="bottom" style="padding-right:2px"><div style="width:9px;height:${hp}px;background:${C.line};border-radius:2px 2px 0 0;font-size:0;line-height:0"> </div></td>
|
|
173
|
+
<td valign="bottom"><div style="width:20px;height:${h}px;background:${d === best && d.v ? C.coral : C.teal};border-radius:3px 3px 0 0;font-size:0;line-height:0"> </div></td>
|
|
174
|
+
</tr></table>
|
|
175
|
+
<div style="font-weight:600;font-size:11px;color:${C.dim};padding-top:4px">${L.weekdays[d.dow]}</div>
|
|
176
|
+
<div style="font-size:10px;color:${C.faint}">${N(d.prevV)}</div></td>`;
|
|
177
|
+
};
|
|
178
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="height:140px"><tr>${days.map(col).join('')}</tr></table>
|
|
179
|
+
<div style="font-size:11px;color:${C.faint};padding-top:6px"><span style="display:inline-block;width:10px;height:10px;background:${C.teal};border-radius:2px;vertical-align:-1px"></span> ${esc(L.visitors)} <span style="display:inline-block;width:10px;height:10px;background:${C.line};border-radius:2px;vertical-align:-1px"></span> ${esc(L.vs)} ${esc(L.compared.replace(/^im Vergleich zur |^compared with /, ''))}</div>`;
|
|
180
|
+
};
|
|
181
|
+
const devicesBar = (rows) => {
|
|
182
|
+
const total = rows.reduce((s, r) => s + r.v, 0) || 1;
|
|
183
|
+
const colors = [C.teal, C.coral, '#7c6cf0', C.faint];
|
|
184
|
+
const parts = rows.map((r, i) => ({ ...r, p: Math.round((r.v / total) * 100), c: colors[i % colors.length] })).filter((r) => r.p > 0);
|
|
185
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${parts.map((r) => `<td width="${r.p}%" style="height:10px;background:${r.c};font-size:0;line-height:0"> </td>`).join('')}</tr></table>
|
|
186
|
+
<div style="font-size:13px;color:${C.text};padding-top:8px">${parts.map((r) => `<span style="white-space:nowrap;margin-right:14px"><span style="display:inline-block;width:10px;height:10px;background:${r.c};border-radius:2px;vertical-align:-1px"></span> ${esc(devName(r.k))} <b>${r.p} %</b></span>`).join(' ')}</div>`;
|
|
187
|
+
};
|
|
188
|
+
const card = (inner) => `<tr><td style="background:${C.card};border:1px solid ${C.line};border-radius:14px;padding:20px 22px">
|
|
189
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${inner}</table></td></tr><tr><td style="height:16px;font-size:0;line-height:0"> </td></tr>`;
|
|
190
|
+
|
|
191
|
+
const compactCard = ({ st, n }) => {
|
|
192
|
+
const link = publicUrl ? `${publicUrl}/?site=${encodeURIComponent(st.domain)}` : '';
|
|
193
|
+
return card(`<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
194
|
+
<td style="font-weight:700;font-size:16px">${esc(st.domain)}</td>
|
|
195
|
+
${link ? `<td align="right"><a href="${esc(link)}" style="font-weight:600;font-size:13px;color:${C.teal};text-decoration:none;white-space:nowrap">${esc(L.open)} →</a></td>` : ''}</tr></table></td></tr>
|
|
196
|
+
${n.cur.pv ? `<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${kpi(L.visitors, N(n.cur.v), delta(n.cur.v, n.prev.v))}${kpi(L.pageviews, N(n.cur.pv), delta(n.cur.pv, n.prev.pv))}${kpi(L.bounce, n.cur.bounce == null ? '–' : n.cur.bounce + ' %', '')}</tr></table></td></tr>
|
|
197
|
+
${n.pages[0] ? `<tr><td style="font-size:13px;color:${C.dim}">${esc(L.pages)}: <span style="color:${C.text}">${esc(shortPath(n.pages[0].k))}</span> · ${esc(L.sources)}: <span style="color:${C.text}">${esc(srcName((n.sources[0] || {}).k || 'Direct'))}</span></td></tr>` : ''}`
|
|
198
|
+
: `<tr><td style="font-size:14px;color:${C.dim};padding-top:10px">${esc(L.quiet)}</td></tr>`}`);
|
|
199
|
+
};
|
|
200
|
+
const siteCard = ({ st, n }) => {
|
|
201
|
+
const link = publicUrl ? `${publicUrl}/?site=${encodeURIComponent(st.domain)}` : '';
|
|
202
|
+
const hl = [];
|
|
203
|
+
if (n.highlights.best) hl.push(`📈 <b>${esc(L.bestDay)}:</b> ${esc(dateFmt(n.highlights.best.day, { weekday: 'long', day: 'numeric', month: 'long' }))}, ${N(n.highlights.best.v)} ${esc(L.visitors)}`);
|
|
204
|
+
if (n.highlights.climber) hl.push(`🚀 <b>${esc(L.climber)}:</b> ${esc(shortPath(n.highlights.climber.k))}, ${N(n.highlights.climber.prev)} → ${N(n.highlights.climber.v)} ${esc(L.visitors)}`);
|
|
205
|
+
if (n.highlights.newSource) hl.push(`✨ <b>${esc(L.newSource)}:</b> ${esc(srcName(n.highlights.newSource.k))}, ${N(n.highlights.newSource.v)} ${esc(L.visitors)}`);
|
|
206
|
+
if (n.highlights.newGoal) hl.push(`🎯 <b>${esc(L.newGoal)}:</b> ${esc(n.highlights.newGoal.k)} (${N(n.highlights.newGoal.c)}×)`);
|
|
207
|
+
const quiet = !n.cur.pv;
|
|
208
|
+
return card(`
|
|
209
|
+
<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
210
|
+
<td style="font-weight:700;font-size:18px;color:${C.text}">${esc(st.domain)}</td>
|
|
211
|
+
${link ? `<td align="right"><a href="${esc(link)}" style="font-weight:600;font-size:13px;color:${C.teal};text-decoration:none;white-space:nowrap">${esc(L.open)} →</a></td>` : ''}
|
|
212
|
+
</tr></table></td></tr>
|
|
213
|
+
${quiet ? `<tr><td style="font-size:14px;color:${C.dim};padding-top:10px">${esc(L.quiet)}</td></tr>` : `
|
|
214
|
+
<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
215
|
+
<tr>${kpi(L.visitors, N(n.cur.v), delta(n.cur.v, n.prev.v))}${kpi(L.pageviews, N(n.cur.pv), delta(n.cur.pv, n.prev.pv))}${kpi(L.vpv, n.cur.vpv == null ? '–' : nf.format(n.cur.vpv), delta(n.cur.vpv, n.prev.vpv))}</tr>
|
|
216
|
+
<tr>${kpi(L.bounce, n.cur.bounce == null ? '–' : n.cur.bounce + ' %', n.prev.v ? delta(n.cur.bounce, n.prev.bounce, { invert: true, points: true }) : '')}${kpi(L.time, dur(n.cur.dur), n.cur.dur != null && n.prev.dur ? delta(n.cur.dur, n.prev.dur) : '')}<td></td></tr>
|
|
217
|
+
</table></td></tr>
|
|
218
|
+
${h3(L.days)}<tr><td>${chart(n.days)}</td></tr>
|
|
219
|
+
${hl.length ? h3(L.highlights) + `<tr><td style="font-size:14px;line-height:1.7;color:${C.text}">${hl.join('<br>')}</td></tr>` : ''}
|
|
220
|
+
${n.shop ? h3(L.shop) + `<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
221
|
+
${kpi(L.revenue, esc(money.format(n.shop.revenue)), n.shop.prevOrders >= 5 ? delta(n.shop.revenue, n.shop.prevRevenue) : '')}
|
|
222
|
+
${kpi(L.orders, N(n.shop.orders), n.shop.prevOrders >= 5 ? delta(n.shop.orders, n.shop.prevOrders) : '')}
|
|
223
|
+
${kpi(L.cr, nf.format(n.shop.cr) + ' %', '')}</tr>
|
|
224
|
+
<tr>${kpi(L.aov, esc(money.format(n.shop.orders ? n.shop.revenue / n.shop.orders : 0)), '')}<td></td><td></td></tr></table></td></tr>` : ''}
|
|
225
|
+
${n.pages.length ? h3(L.pages) + barRows(n.pages, (r) => `<span style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px">${esc(shortPath(r.k))}</span>`, { sub: (r) => delta(r.v, r.prev) }) : ''}
|
|
226
|
+
${n.sources.length ? h3(L.sources) + barRows(n.sources, (r) => esc(srcName(r.k)), { sub: (r) => delta(r.v, r.prev) }) : ''}
|
|
227
|
+
${n.countries.length ? h3(L.countries) + barRows(n.countries, (r) => `${r.k === '?' ? '🌐' : flag(r.k)} ${esc(country(r.k))}`) : ''}
|
|
228
|
+
${n.devices.length ? h3(L.devices) + `<tr><td>${devicesBar(n.devices)}</td></tr>` : ''}
|
|
229
|
+
${n.goals.length ? h3(L.goals) + n.goals.map((g) => `<tr><td style="padding:3px 0"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
230
|
+
<td style="font-size:13px;color:${C.text}">🎯 ${esc(g.k)}</td>
|
|
231
|
+
<td align="right" style="font-weight:600;font-size:13px;color:${C.text};white-space:nowrap">${N(g.c)}× <span style="font-weight:400">${delta(g.c, g.prev)}</span></td></tr></table></td></tr>`).join('') : ''}
|
|
232
|
+
${n.notes.length ? h3(L.notes) + `<tr><td style="font-size:13px;line-height:1.7;color:${C.text}">${n.notes.map((x) => `📌 <b>${esc(dateFmt(x.day, { weekday: 'short', day: 'numeric', month: 'numeric' }))}</b> ${esc(x.text)}`).join('<br>')}</td></tr>` : ''}
|
|
233
|
+
`}`);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const trendWord = sumV > sumPrevV ? L.more : sumV < sumPrevV ? L.fewer : L.same;
|
|
237
|
+
const trendPct = sumPrevV ? Math.abs(Math.round(((sumV - sumPrevV) / sumPrevV) * 100)) : null;
|
|
238
|
+
const summary = blocks.length > 1 ? card(`
|
|
239
|
+
<tr><td style="font-weight:700;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:${C.faint};padding-bottom:4px">${esc(L.allSites)}</td></tr>
|
|
240
|
+
<tr><td><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${kpi(L.visitors, N(sumV), delta(sumV, sumPrevV))}${kpi(L.pageviews, N(sumPv), delta(sumPv, sumPrevPv))}${kpi(L.sitesN, N(blocks.length), '')}</tr></table></td></tr>
|
|
241
|
+
${barRows(blocks.map((b) => ({ k: b.st.domain, v: b.n.cur.v, prev: b.n.prev.v })).sort((x, y) => y.v - x.v), (r) => esc(r.k), { sub: (r) => delta(r.v, r.prev) })}`) : '';
|
|
242
|
+
// Gmail clips mails above ~102 KB ("[Message clipped]") — then the end, with the opt-out,
|
|
243
|
+
// is hidden. Keep the HTML under a budget: the smallest sites switch to a compact card first.
|
|
244
|
+
const BUDGET = 80000;
|
|
245
|
+
const full = blocks.map(siteCard), small = blocks.map(compactCard);
|
|
246
|
+
const cards = full.slice();
|
|
247
|
+
const order = blocks.map((b, i) => i).sort((i, j) => blocks[i].n.cur.v - blocks[j].n.cur.v);
|
|
248
|
+
let size = cards.reduce((t, c) => t + Buffer.byteLength(c), 0), compacted = 0;
|
|
249
|
+
for (const i of order) {
|
|
250
|
+
if (size <= BUDGET - 8000) break;
|
|
251
|
+
size += Buffer.byteLength(small[i]) - Buffer.byteLength(cards[i]); cards[i] = small[i]; compacted++;
|
|
252
|
+
}
|
|
253
|
+
const preheader = `${N(sumV)} ${L.visitors} · ${pct(sumV, sumPrevV)} · ${range}`;
|
|
254
|
+
const html = `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light only"><title>Tacilytics · ${esc(L.weekly)}</title></head>
|
|
255
|
+
<body style="margin:0;padding:0;background:${C.bg};font-family:${FONT};color:${C.text}">
|
|
256
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0">${esc(preheader)}</div>
|
|
257
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${C.bg};font-family:${FONT}"><tr><td align="center" style="padding:28px 12px">
|
|
258
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;font-family:${FONT}">
|
|
259
|
+
<tr><td style="padding:0 4px 18px">
|
|
260
|
+
<div style="font-weight:800;font-size:22px;color:${C.text};letter-spacing:-.01em">Tacilytics<span style="color:${C.coral}">.</span></div>
|
|
261
|
+
<div style="font-size:13px;color:${C.dim};padding-top:2px">${esc(L.weekly)} · ${esc(range)} · ${esc(L.compared)}</div>
|
|
262
|
+
</td></tr>
|
|
263
|
+
<tr><td style="padding:0 4px 18px;font-size:15px;line-height:1.6;color:${C.text}">${esc(L.hi)}${name ? ' ' + esc(name) : ''}, ${esc(L.intro)}<br>
|
|
264
|
+
<b>${N(sumV)} ${esc(L.visitors)}</b>${trendPct != null && sumV !== sumPrevV ? `, ${trendPct} % ${esc(trendWord)}` : sumPrevV ? `, ${esc(trendWord)}` : ''}.</td></tr>
|
|
265
|
+
${summary}
|
|
266
|
+
${cards.join('')}
|
|
267
|
+
${compacted ? `<tr><td style="padding:0 4px 12px;font-size:12px;color:${C.faint}">${esc(L.compactNote)}</td></tr>` : ''}
|
|
268
|
+
<tr><td style="padding:6px 4px 0;font-size:12px;line-height:1.6;color:${C.faint}">${esc(L.footer)}<br>${esc(L.off)}</td></tr>
|
|
269
|
+
</table></td></tr></table></body></html>`;
|
|
270
|
+
|
|
271
|
+
// plain-text part: the same content, readable in any client
|
|
272
|
+
const T = [];
|
|
273
|
+
T.push(`Tacilytics · ${L.weekly} · ${range}`, `${L.hi}${name ? ' ' + name : ''}, ${L.intro}`, '');
|
|
274
|
+
for (const { st, n } of blocks) {
|
|
275
|
+
T.push(`━━ ${st.domain} ━━`);
|
|
276
|
+
if (!n.cur.pv) { T.push(L.quiet, ''); continue; }
|
|
277
|
+
T.push(` ${L.visitors.padEnd(20)} ${N(n.cur.v)} (${pct(n.cur.v, n.prev.v)})`);
|
|
278
|
+
T.push(` ${L.pageviews.padEnd(20)} ${N(n.cur.pv)} (${pct(n.cur.pv, n.prev.pv)})`);
|
|
279
|
+
T.push(` ${L.bounce.padEnd(20)} ${n.cur.bounce == null ? '–' : n.cur.bounce + ' %'}`);
|
|
280
|
+
T.push(` ${L.time.padEnd(20)} ${dur(n.cur.dur).replace(' ', ' ')}`);
|
|
281
|
+
T.push(` ${L.days}: ` + n.days.map((d) => `${L.weekdays[d.dow]} ${N(d.v)}`).join(' · '));
|
|
282
|
+
if (n.shop) T.push(` ${L.revenue} ${money.format(n.shop.revenue)} · ${L.orders} ${N(n.shop.orders)} · ${L.cr} ${nf.format(n.shop.cr)} %`);
|
|
283
|
+
if (n.pages.length) T.push(` ${L.pages}: ` + n.pages.map((x) => `${shortPath(x.k)} (${N(x.v)})`).join(', '));
|
|
284
|
+
if (n.sources.length) T.push(` ${L.sources}: ` + n.sources.map((x) => `${srcName(x.k)} (${N(x.v)})`).join(', '));
|
|
285
|
+
if (n.countries.length) T.push(` ${L.countries}: ` + n.countries.map((x) => `${country(x.k)} (${N(x.v)})`).join(', '));
|
|
286
|
+
if (n.goals.length) T.push(` ${L.goals}: ` + n.goals.map((x) => `${x.k} (${N(x.c)})`).join(', '));
|
|
287
|
+
if (n.notes.length) T.push(` ${L.notes}: ` + n.notes.map((x) => `${x.day} ${x.text}`).join(' · '));
|
|
288
|
+
if (publicUrl) T.push(` ${publicUrl}/?site=${encodeURIComponent(st.domain)}`);
|
|
289
|
+
T.push('');
|
|
290
|
+
}
|
|
291
|
+
T.push(L.off);
|
|
292
|
+
const pctAll = pct(sumV, sumPrevV);
|
|
293
|
+
const subject = (blocks.length === 1 ? L.subjOne(blocks[0].st.domain, N(sumV), pctAll) : L.subjMany(blocks.length, N(sumV), pctAll)).slice(0, 180);
|
|
294
|
+
return { subject, text: T.join('\n') + '\n', html: html.replace(/\n\s+/g, '\n') };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { build, siteWeek };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { createReport, REPORT_LANGS: Object.keys(TXT) };
|