@ak--47/dungeon-master 1.4.5 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +182 -0
- package/HOOKS.md +1256 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +87 -0
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +111 -65
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +300 -333
- package/dungeons/vertical/community.js +290 -255
- package/dungeons/vertical/crypto.js +400 -391
- package/dungeons/vertical/dating.js +421 -375
- package/dungeons/vertical/devtools.js +346 -298
- package/dungeons/vertical/ecommerce.js +322 -394
- package/dungeons/vertical/education.js +380 -325
- package/dungeons/vertical/fintech.js +371 -325
- package/dungeons/vertical/fitness.js +345 -291
- package/dungeons/vertical/food-delivery.js +352 -307
- package/dungeons/vertical/gaming.js +490 -444
- package/dungeons/vertical/healthcare.js +311 -262
- package/dungeons/vertical/insurance-application.js +437 -409
- package/dungeons/vertical/logistics.js +278 -252
- package/dungeons/vertical/marketplace.js +340 -323
- package/dungeons/vertical/media.js +390 -335
- package/dungeons/vertical/real-estate.js +402 -347
- package/dungeons/vertical/sass.js +331 -333
- package/dungeons/vertical/social.js +377 -316
- package/dungeons/vertical/travel.js +302 -295
- package/index.js +64 -7
- package/lib/core/config-validator.js +378 -17
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +76 -2
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +598 -48
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +53 -11
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +157 -109
- package/lib/verify/counting.js +360 -0
- package/lib/verify/emulate-breakdown.js +531 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +20 -0
- package/lib/verify/schema-validator.js +3 -1
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +14 -3
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +353 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting and aggregation helpers matching Mixpanel's analytics semantics.
|
|
3
|
+
*
|
|
4
|
+
* These primitives differ from naive SQL in important ways. Each helper
|
|
5
|
+
* documents the Mixpanel source file that defines the rule and the
|
|
6
|
+
* specific divergence from `COUNT(*)` / `AVG(x)` / etc.
|
|
7
|
+
*
|
|
8
|
+
* References (from `mixpanel/analytics`):
|
|
9
|
+
* - `backend/arb/reader/queries/addiction_query.cpp` — distinct-period counting
|
|
10
|
+
* - `backend/arb/reader/queries/normal_query.cpp` — null-aware AVG/SUM/MIN/MAX
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
14
|
+
|
|
15
|
+
const SECONDS_PER_UNIT = {
|
|
16
|
+
hour: 3600,
|
|
17
|
+
day: 86400,
|
|
18
|
+
week: 7 * 86400,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count distinct time periods on which a user fired a given event.
|
|
23
|
+
*
|
|
24
|
+
* Two related rules exist in Mixpanel:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Calendar bucket** (default here, `algorithm: 'calendar'`):
|
|
27
|
+
* `COUNT(DISTINCT date_trunc(unit, time))` in UTC. This is what the
|
|
28
|
+
* Mixpanel UI presents — frequency distribution charts bucket events
|
|
29
|
+
* into calendar hours/days/weeks.
|
|
30
|
+
*
|
|
31
|
+
* 2. **Rolling window** (`algorithm: 'rolling'`): the addiction_query.cpp
|
|
32
|
+
* rule, `qtz_time >= last_counted + seconds_for_unit(unit)`. This is
|
|
33
|
+
* Mixpanel's internal C++ implementation. It diverges from calendar
|
|
34
|
+
* bucketing at unit boundaries — events at 23:59 and 00:01 next day
|
|
35
|
+
* register as 1 rolling-window period (gap 120s < 86400s) but 2
|
|
36
|
+
* calendar-day periods.
|
|
37
|
+
*
|
|
38
|
+
* The default is `calendar` because:
|
|
39
|
+
* - It matches what users actually see in Mixpanel reports.
|
|
40
|
+
* - It aligns with `injectOnNewDays`, which classifies days by
|
|
41
|
+
* `Math.floor(t / DAY_MS)` to find empty days. Mixing the two
|
|
42
|
+
* algorithms causes the atom and verifier to disagree at boundaries.
|
|
43
|
+
*
|
|
44
|
+
* Use `algorithm: 'rolling'` only when you're verifying behavior that
|
|
45
|
+
* specifically depends on the C++ rolling-window check.
|
|
46
|
+
*
|
|
47
|
+
* Reference: `mixpanel/analytics`
|
|
48
|
+
* - calendar bucketing: implicit in the UI / Insights reports
|
|
49
|
+
* - rolling-window: `backend/arb/reader/queries/addiction_query.cpp`
|
|
50
|
+
*
|
|
51
|
+
* @param {Object[]} events - Events to scan (mixed types OK).
|
|
52
|
+
* @param {string} eventName - Event name to filter for.
|
|
53
|
+
* @param {('hour'|'day'|'week')} [unit='day']
|
|
54
|
+
* @param {Object} [options]
|
|
55
|
+
* @param {('calendar'|'rolling')} [options.algorithm='calendar']
|
|
56
|
+
* @returns {number} Distinct period count.
|
|
57
|
+
*/
|
|
58
|
+
export function countDistinctPeriods(events, eventName, unit = 'day', options = {}) {
|
|
59
|
+
const seconds = SECONDS_PER_UNIT[unit];
|
|
60
|
+
if (!seconds) throw new Error(`countDistinctPeriods: unsupported unit "${unit}"`);
|
|
61
|
+
if (!Array.isArray(events) || !events.length) return 0;
|
|
62
|
+
const matches = events
|
|
63
|
+
.filter(e => e && e.event === eventName)
|
|
64
|
+
.map(e => toMs(e.time))
|
|
65
|
+
.filter(ms => Number.isFinite(ms));
|
|
66
|
+
if (!matches.length) return 0;
|
|
67
|
+
const unitMs = seconds * 1000;
|
|
68
|
+
const { algorithm = 'calendar' } = options;
|
|
69
|
+
|
|
70
|
+
if (algorithm === 'calendar') {
|
|
71
|
+
// Calendar bucket — UTC floor by unit. Matches what Mixpanel's UI
|
|
72
|
+
// shows and what `injectOnNewDays` uses internally.
|
|
73
|
+
const buckets = new Set();
|
|
74
|
+
for (const t of matches) buckets.add(Math.floor(t / unitMs));
|
|
75
|
+
return buckets.size;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Rolling window — addiction_query.cpp semantics.
|
|
79
|
+
matches.sort((a, b) => a - b);
|
|
80
|
+
let count = 0;
|
|
81
|
+
let lastCountedMs = -Infinity;
|
|
82
|
+
for (const t of matches) {
|
|
83
|
+
if (t >= lastCountedMs + unitMs) {
|
|
84
|
+
count++;
|
|
85
|
+
lastCountedMs = t;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Null-aware average matching Mixpanel's aggregation semantics.
|
|
93
|
+
*
|
|
94
|
+
* Reference: `backend/arb/reader/queries/normal_query.cpp` ACTION_TYPE_AVERAGE:
|
|
95
|
+
*
|
|
96
|
+
* if (action_value.type == VALUE_TYPE_NUMBER && !std::isnan(value)) {
|
|
97
|
+
* v->average.sum += number;
|
|
98
|
+
* v->average.count += upsampling_factor;
|
|
99
|
+
* }
|
|
100
|
+
*
|
|
101
|
+
* Skips null, undefined, NaN, and non-numeric values from BOTH numerator
|
|
102
|
+
* and denominator. Returns null when no numeric values exist.
|
|
103
|
+
*
|
|
104
|
+
* Differs from naive `SUM(x) / COUNT(*)` which inflates the denominator
|
|
105
|
+
* by counting rows where x is missing — diluting the average toward 0.
|
|
106
|
+
*
|
|
107
|
+
* @param {*[]} values
|
|
108
|
+
* @returns {number|null}
|
|
109
|
+
*/
|
|
110
|
+
export function nullAwareAvg(values) {
|
|
111
|
+
if (!Array.isArray(values) || !values.length) return null;
|
|
112
|
+
let sum = 0;
|
|
113
|
+
let count = 0;
|
|
114
|
+
for (const v of values) {
|
|
115
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
116
|
+
sum += v;
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return count ? sum / count : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Null-aware sum. Skips null/undefined/NaN/non-numeric silently.
|
|
125
|
+
*
|
|
126
|
+
* Reference: `normal_query.cpp` ACTION_TYPE_SUM — same numeric guard as AVG.
|
|
127
|
+
* Differs from naive SQL SUM only when missing values are coalesced to 0
|
|
128
|
+
* upstream; in JS arrays missing values are typically `undefined` which
|
|
129
|
+
* produces NaN under `+`.
|
|
130
|
+
*
|
|
131
|
+
* @param {*[]} values
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
export function nullAwareSum(values) {
|
|
135
|
+
if (!Array.isArray(values) || !values.length) return 0;
|
|
136
|
+
let sum = 0;
|
|
137
|
+
for (const v of values) {
|
|
138
|
+
if (typeof v === 'number' && Number.isFinite(v)) sum += v;
|
|
139
|
+
}
|
|
140
|
+
return sum;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Null-aware min/max. Returns null when no numeric values exist.
|
|
145
|
+
*
|
|
146
|
+
* Reference: `normal_query.cpp` ACTION_TYPE_EXTREMES — only records numeric
|
|
147
|
+
* values. Mixpanel starts max at -Infinity and min at +Infinity; we return
|
|
148
|
+
* null instead of those sentinels when no values were recorded.
|
|
149
|
+
*
|
|
150
|
+
* @param {*[]} values
|
|
151
|
+
* @param {('min'|'max')} mode
|
|
152
|
+
* @returns {number|null}
|
|
153
|
+
*/
|
|
154
|
+
export function nullAwareExtreme(values, mode) {
|
|
155
|
+
if (!Array.isArray(values) || !values.length) return null;
|
|
156
|
+
let extreme = mode === 'min' ? Infinity : -Infinity;
|
|
157
|
+
let any = false;
|
|
158
|
+
for (const v of values) {
|
|
159
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
160
|
+
any = true;
|
|
161
|
+
if (mode === 'min') {
|
|
162
|
+
if (v < extreme) extreme = v;
|
|
163
|
+
} else {
|
|
164
|
+
if (v > extreme) extreme = v;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return any ? extreme : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* v1.5.1: count distinct values of a flat property across events.
|
|
173
|
+
*
|
|
174
|
+
* Mixpanel parity: `COUNT_DISTINCT(prop)` aggregator (Insights). Skips
|
|
175
|
+
* null/undefined/empty-string values. Returns the distinct-value count plus
|
|
176
|
+
* the top-N most-frequent values (default 25, matching Mixpanel UI default).
|
|
177
|
+
*
|
|
178
|
+
* Property keys are FLAT on event records per the dungeon-master schema
|
|
179
|
+
* contract (see HOOKS.md §1) — no dot-path support.
|
|
180
|
+
*
|
|
181
|
+
* @param {Object[]} events
|
|
182
|
+
* @param {string} property - Flat property name.
|
|
183
|
+
* @param {Object} [options]
|
|
184
|
+
* @param {string} [options.event] - Optional event-name filter.
|
|
185
|
+
* @param {number} [options.topN=25] - Number of top values to include in the result.
|
|
186
|
+
* @returns {{ distinct_count: number, top_values: Array<{ value: any, count: number }> }}
|
|
187
|
+
*/
|
|
188
|
+
export function countDistinctValues(events, property, options = {}) {
|
|
189
|
+
if (!Array.isArray(events)) throw new Error('countDistinctValues: events must be an array');
|
|
190
|
+
if (typeof property !== 'string' || !property) throw new Error('countDistinctValues: property is required');
|
|
191
|
+
const topN = Number.isFinite(options.topN) && options.topN > 0 ? Math.floor(options.topN) : 25;
|
|
192
|
+
const filterEvent = typeof options.event === 'string' && options.event ? options.event : null;
|
|
193
|
+
const valueCounts = new Map();
|
|
194
|
+
for (const e of events) {
|
|
195
|
+
if (!e || typeof e !== 'object') continue;
|
|
196
|
+
if (filterEvent && e.event !== filterEvent) continue;
|
|
197
|
+
const v = e[property];
|
|
198
|
+
if (v === null || v === undefined || v === '') continue;
|
|
199
|
+
// Hashable normalization — Map keys distinguish primitives but objects
|
|
200
|
+
// use reference identity. For Mixpanel parity, stringify non-primitives.
|
|
201
|
+
const key = (typeof v === 'object') ? JSON.stringify(v) : v;
|
|
202
|
+
valueCounts.set(key, (valueCounts.get(key) || 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
const sorted = [...valueCounts.entries()]
|
|
205
|
+
.sort((a, b) => b[1] - a[1])
|
|
206
|
+
.slice(0, topN)
|
|
207
|
+
.map(([value, count]) => ({ value, count }));
|
|
208
|
+
return { distinct_count: valueCounts.size, top_values: sorted };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Partition events into time buckets by UTC calendar (`day`, `week`, or
|
|
213
|
+
* `month`). Used by `emulateBreakdown` when `timeBucket` is set to slice any
|
|
214
|
+
* breakdown into a trend over time.
|
|
215
|
+
*
|
|
216
|
+
* Period labels:
|
|
217
|
+
* - `'day'` → `YYYY-MM-DD`
|
|
218
|
+
* - `'week'` → ISO week `YYYY-Www` (Monday-anchored)
|
|
219
|
+
* - `'month'` → `YYYY-MM`
|
|
220
|
+
*
|
|
221
|
+
* Mixpanel parity: bucket boundaries are computed in UTC. Production Mixpanel
|
|
222
|
+
* uses query timezone (qtz); pass timestamps already shifted to qtz if you
|
|
223
|
+
* need that behavior. ISO week is Monday-anchored
|
|
224
|
+
* (matches `eval_node.c:3641-3643`).
|
|
225
|
+
*
|
|
226
|
+
* Empty-bucket backfill (Mixpanel `normal_query.cpp:352-356, 310-313` emits
|
|
227
|
+
* zero rows for empty intervals): when `options.from` AND `options.to` are
|
|
228
|
+
* provided, the result enumerates every bucket in `[from, to]` and emits
|
|
229
|
+
* `{ period, events: [] }` for buckets with no events. Without `from`/`to`,
|
|
230
|
+
* only buckets that contain at least one event are returned.
|
|
231
|
+
*
|
|
232
|
+
* @param {Object[]} events
|
|
233
|
+
* @param {('day'|'week'|'month')} bucket
|
|
234
|
+
* @param {Object} [options]
|
|
235
|
+
* @param {number|string} [options.from] - Inclusive range start (ms or ISO).
|
|
236
|
+
* @param {number|string} [options.to] - Inclusive range end (ms or ISO).
|
|
237
|
+
* @returns {Array<{ period: string, events: Object[] }>}
|
|
238
|
+
*/
|
|
239
|
+
export function partitionByTimeBucket(events, bucket, options = {}) {
|
|
240
|
+
const groups = new Map();
|
|
241
|
+
if (Array.isArray(events)) {
|
|
242
|
+
for (const ev of events) {
|
|
243
|
+
const ms = toMs(ev && ev.time);
|
|
244
|
+
if (!Number.isFinite(ms)) continue;
|
|
245
|
+
const period = formatBucket(ms, bucket);
|
|
246
|
+
if (!groups.has(period)) groups.set(period, []);
|
|
247
|
+
groups.get(period).push(ev);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const fromMs = options.from != null ? toMs(options.from) : null;
|
|
251
|
+
const toMsBound = options.to != null ? toMs(options.to) : null;
|
|
252
|
+
if (Number.isFinite(fromMs) && Number.isFinite(toMsBound)) {
|
|
253
|
+
// Enumerate every bucket period in [from, to] and seed empties.
|
|
254
|
+
for (const period of enumerateBucketPeriods(fromMs, toMsBound, bucket)) {
|
|
255
|
+
if (!groups.has(period)) groups.set(period, []);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return [...groups.entries()]
|
|
259
|
+
.map(([period, evs]) => ({ period, events: evs }))
|
|
260
|
+
.sort((a, b) => a.period.localeCompare(b.period));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Enumerate canonical bucket period labels covering `[fromMs, toMs]` UTC.
|
|
265
|
+
* Used to backfill empty rows when the caller supplies a trend axis.
|
|
266
|
+
*
|
|
267
|
+
* @param {number} fromMs
|
|
268
|
+
* @param {number} toMs
|
|
269
|
+
* @param {('day'|'week'|'month')} bucket
|
|
270
|
+
* @returns {string[]}
|
|
271
|
+
*/
|
|
272
|
+
function enumerateBucketPeriods(fromMs, toMs, bucket) {
|
|
273
|
+
const out = [];
|
|
274
|
+
if (!(toMs >= fromMs)) return out;
|
|
275
|
+
if (bucket === 'day') {
|
|
276
|
+
const start = Math.floor(fromMs / 86400_000);
|
|
277
|
+
const end = Math.floor(toMs / 86400_000);
|
|
278
|
+
for (let d = start; d <= end; d++) out.push(formatBucket(d * 86400_000, 'day'));
|
|
279
|
+
} else if (bucket === 'week') {
|
|
280
|
+
// Walk by 7 days starting from fromMs; rely on label dedup via Set.
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
for (let t = fromMs; t <= toMs; t += 7 * 86400_000) {
|
|
283
|
+
const p = formatBucket(t, 'week'); if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
284
|
+
}
|
|
285
|
+
const last = formatBucket(toMs, 'week');
|
|
286
|
+
if (!seen.has(last)) out.push(last);
|
|
287
|
+
} else if (bucket === 'month') {
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
const start = new Date(fromMs);
|
|
290
|
+
const end = new Date(toMs);
|
|
291
|
+
let y = start.getUTCFullYear(), m = start.getUTCMonth();
|
|
292
|
+
const yEnd = end.getUTCFullYear(), mEnd = end.getUTCMonth();
|
|
293
|
+
while (y < yEnd || (y === yEnd && m <= mEnd)) {
|
|
294
|
+
const p = formatBucket(Date.UTC(y, m, 1), 'month');
|
|
295
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
296
|
+
m++; if (m > 11) { m = 0; y++; }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function pad2(n) { return n < 10 ? `0${n}` : `${n}`; }
|
|
303
|
+
|
|
304
|
+
function formatBucket(ms, bucket) {
|
|
305
|
+
const d = new Date(ms);
|
|
306
|
+
if (bucket === 'day') {
|
|
307
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
308
|
+
}
|
|
309
|
+
if (bucket === 'month') {
|
|
310
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
|
|
311
|
+
}
|
|
312
|
+
if (bucket === 'week') {
|
|
313
|
+
// ISO week: Mon-anchored. Algorithm: shift to Thursday of the week,
|
|
314
|
+
// take year of that Thursday + week number relative to Jan-1 of that
|
|
315
|
+
// year's Monday-of-Thursday.
|
|
316
|
+
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
317
|
+
const dayNum = (date.getUTCDay() + 6) % 7; // Mon=0..Sun=6
|
|
318
|
+
date.setUTCDate(date.getUTCDate() - dayNum + 3); // Thursday of this week
|
|
319
|
+
const year = date.getUTCFullYear();
|
|
320
|
+
const jan4 = new Date(Date.UTC(year, 0, 4));
|
|
321
|
+
const jan4DayNum = (jan4.getUTCDay() + 6) % 7;
|
|
322
|
+
const week1Mon = new Date(Date.UTC(year, 0, 4 - jan4DayNum));
|
|
323
|
+
const weekNum = Math.floor((date.getTime() - week1Mon.getTime()) / (7 * 86400_000)) + 1;
|
|
324
|
+
return `${year}-W${pad2(weekNum)}`;
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`partitionByTimeBucket: unsupported bucket "${bucket}"`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Bin a user by their distinct-period count of an event. Combines
|
|
331
|
+
* `countDistinctPeriods` with bin classification for cohort assignment.
|
|
332
|
+
*
|
|
333
|
+
* Each bin entry is `[min, max]` with `min` inclusive, `max` exclusive.
|
|
334
|
+
* Returns the first matching bin name, or `null` if no bin matches.
|
|
335
|
+
*
|
|
336
|
+
* Uses calendar-bucket counting by default (matches Mixpanel UI). Pass
|
|
337
|
+
* `options.algorithm: 'rolling'` to use the addiction_query.cpp rule
|
|
338
|
+
* instead — see `countDistinctPeriods` for the difference.
|
|
339
|
+
*
|
|
340
|
+
* Replaces total-event counting for any analysis that targets Mixpanel's
|
|
341
|
+
* frequency distribution (which counts distinct periods, not total events).
|
|
342
|
+
*
|
|
343
|
+
* @param {Object[]} events
|
|
344
|
+
* @param {string} eventName
|
|
345
|
+
* @param {Object<string, [number, number]>} bins
|
|
346
|
+
* @param {('hour'|'day'|'week')} [unit='day']
|
|
347
|
+
* @param {Object} [options]
|
|
348
|
+
* @param {('calendar'|'rolling')} [options.algorithm='calendar']
|
|
349
|
+
* @returns {string|null}
|
|
350
|
+
*/
|
|
351
|
+
export function binByDistinctPeriods(events, eventName, bins, unit = 'day', options = {}) {
|
|
352
|
+
const periods = countDistinctPeriods(events, eventName, unit, options);
|
|
353
|
+
if (!bins || typeof bins !== 'object') return null;
|
|
354
|
+
for (const [name, range] of Object.entries(bins)) {
|
|
355
|
+
if (!Array.isArray(range) || range.length !== 2) continue;
|
|
356
|
+
const [min, max] = range;
|
|
357
|
+
if (periods >= min && periods < max) return name;
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|