@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
|
@@ -1,55 +1,45 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-fitness";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.2;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
|
-
|
|
16
|
-
dayjs.extend(utc);
|
|
17
|
-
const chance = u.initChance(SEED);
|
|
18
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: FitQuest
|
|
12
|
+
* APP: Fitness & wellness app for workout tracking, meal planning,
|
|
13
|
+
* social fitness challenges, and AI coaching. Core loop: sign
|
|
14
|
+
* up → plan workout → complete workout → track progress.
|
|
15
|
+
* Revenue: free / monthly ($12.99, 7-day trial) / annual
|
|
16
|
+
* ($99.99) / family ($149.99).
|
|
17
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: account created → workout planned → workout completed → progress checked
|
|
19
|
+
*
|
|
20
|
+
* EVENTS (18):
|
|
21
|
+
* workout completed (8) > app session (8) > meal logged (7) > workout planned (6)
|
|
22
|
+
* > progress checked (5) > notification received (5) > leaderboard viewed (4)
|
|
23
|
+
* > nutrition plan viewed (4) > challenge joined (3) > coach session (3)
|
|
24
|
+
* > heart rate recorded (3) > achievement unlocked (2) > friend added (2)
|
|
25
|
+
* > challenge completed (2) > subscription managed (2) > profile updated (2)
|
|
26
|
+
* > account created (1) > account deactivated (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (5):
|
|
29
|
+
* - Onboarding: account created → profile updated → workout planned → workout completed (45%)
|
|
30
|
+
* - Workout Loop: workout planned → workout completed → progress checked (45%, reentry)
|
|
31
|
+
* - Social Engagement: friend added → leaderboard viewed → challenge joined (35%)
|
|
32
|
+
* - Challenge Completion: challenge joined → workout completed → challenge completed → achievement unlocked (30%)
|
|
33
|
+
* - Coaching Path: coach session → workout planned → workout completed → progress checked (50%)
|
|
34
|
+
*
|
|
35
|
+
* USER PROPS: fitness_level, segment, streak_days, total_workouts, preferred_workout, goal, Platform, workout_type, subscription_tier
|
|
36
|
+
* SUPER PROPS: Platform, workout_type, subscription_tier
|
|
37
|
+
* SCD PROPS: fitness_level (beginner/intermediate/advanced/elite, monthly fuzzy, max 8)
|
|
38
|
+
* GROUPS: none
|
|
46
39
|
*/
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
51
|
-
* ═══════════════════════════════════════════════════════════════
|
|
52
|
-
*
|
|
41
|
+
// ── HOOK STORIES ──
|
|
42
|
+
/*
|
|
53
43
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
54
44
|
* via raw-prop breakdowns (HOD, day, segment) or behavioral cohorts.
|
|
55
45
|
*
|
|
@@ -310,31 +300,309 @@ const chance = u.initChance(SEED);
|
|
|
310
300
|
* Workout Magic Number | over D30+ post/pre | 1x | 0.29x | -71%
|
|
311
301
|
*/
|
|
312
302
|
|
|
303
|
+
// ── SCALE ──
|
|
304
|
+
const SEED = "dm4-fitness";
|
|
305
|
+
const NUM_USERS = 10_000;
|
|
306
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
307
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
308
|
+
const EVENTS_PER_DAY = 1.2;
|
|
309
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
310
|
+
|
|
311
|
+
const chance = u.initChance(SEED);
|
|
312
|
+
|
|
313
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
314
|
+
const MORNING_HOUR_START = 5;
|
|
315
|
+
const MORNING_HOUR_END = 9;
|
|
316
|
+
const MORNING_CALORIE_MULT = 1.3;
|
|
317
|
+
|
|
318
|
+
const AI_LAUNCH_DAY = 35;
|
|
319
|
+
const AI_ADOPTION_LIKELIHOOD = 40;
|
|
320
|
+
const AI_DURATION_MULT = 1.2;
|
|
321
|
+
|
|
322
|
+
const GROUP_LAUNCH_DAY = 55;
|
|
323
|
+
const GROUP_ADOPTION_LIKELIHOOD = 30;
|
|
324
|
+
|
|
325
|
+
const STREAK_MIN_WORKOUTS = 2;
|
|
326
|
+
const STREAK_LINEAR_CAP = 3; // workouts 2-4 (count - 1 capped to 3) → 1 achievement each
|
|
327
|
+
const STREAK_SUPER_LINEAR_MULT = 4; // workouts 5+ → 4 achievements each
|
|
328
|
+
|
|
329
|
+
const SOCIAL_FRIEND_THRESHOLD = 3;
|
|
330
|
+
const SOCIAL_CHALLENGE_CLONE_FACTOR = 0.5;
|
|
331
|
+
|
|
332
|
+
const RESOLVER_EVENT_THRESHOLD = 30;
|
|
333
|
+
const RESOLVER_CLIFF_DAYS = 14;
|
|
334
|
+
const RESOLVER_DROP_LIKELIHOOD = 70;
|
|
335
|
+
|
|
336
|
+
const COACH_SESSION_SATISFACTION_MIN = 4.0;
|
|
337
|
+
const COACH_SESSION_SATISFACTION_MAX = 5.0;
|
|
338
|
+
|
|
339
|
+
const COACH_TOTAL_WORKOUTS_MIN = 200;
|
|
340
|
+
const COACH_TOTAL_WORKOUTS_MAX = 500;
|
|
341
|
+
const COACH_STREAK_DAYS_MIN = 60;
|
|
342
|
+
const COACH_STREAK_DAYS_MAX = 365;
|
|
343
|
+
|
|
344
|
+
const ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD = 30;
|
|
345
|
+
|
|
346
|
+
const TTC_ANNUAL_FACTOR = 0.77;
|
|
347
|
+
const TTC_FREE_FACTOR = 1.25;
|
|
348
|
+
|
|
349
|
+
const WORKOUT_SWEET_MIN = 12;
|
|
350
|
+
const WORKOUT_SWEET_MAX = 14;
|
|
351
|
+
const WORKOUT_OVER_THRESHOLD = 15;
|
|
352
|
+
const WORKOUT_DURATION_BOOST = 1.35;
|
|
353
|
+
const WORKOUT_OVER_CUTOFF_DAYS = 30;
|
|
354
|
+
const WORKOUT_OVER_DROP_LIKELIHOOD = 65;
|
|
355
|
+
|
|
356
|
+
// ── HELPER FUNCTIONS ──
|
|
357
|
+
function handleUserHooks(record) {
|
|
358
|
+
// H7: COACH PROFILE ENRICHMENT — coach segment users get high
|
|
359
|
+
// total_workouts + streak_days. Also assign subscription_tier by segment.
|
|
360
|
+
if (record.segment === "coach") {
|
|
361
|
+
record.total_workouts = chance.integer({ min: COACH_TOTAL_WORKOUTS_MIN, max: COACH_TOTAL_WORKOUTS_MAX });
|
|
362
|
+
record.streak_days = chance.integer({ min: COACH_STREAK_DAYS_MIN, max: COACH_STREAK_DAYS_MAX });
|
|
363
|
+
}
|
|
364
|
+
// Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
|
|
365
|
+
if (record.segment === "athlete") {
|
|
366
|
+
record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
|
|
367
|
+
} else if (record.segment === "coach") {
|
|
368
|
+
record.subscription_tier = chance.pickone(["annual", "family", "family"]);
|
|
369
|
+
} else if (record.segment === "social") {
|
|
370
|
+
record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
|
|
371
|
+
} else if (record.segment === "resolver") {
|
|
372
|
+
record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
|
|
373
|
+
} else {
|
|
374
|
+
record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
|
|
375
|
+
}
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleFunnelPostHooks(record, meta) {
|
|
380
|
+
// H9: WORKOUT LOOP TIME-TO-CONVERT — Annual/family complete 1.3x faster
|
|
381
|
+
// (factor 0.77); Free 1.25x slower (factor 1.25).
|
|
382
|
+
const tier = meta?.profile?.subscription_tier;
|
|
383
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
384
|
+
const factor = (
|
|
385
|
+
tier === "annual" || tier === "family" ? TTC_ANNUAL_FACTOR :
|
|
386
|
+
tier === "free" ? TTC_FREE_FACTOR :
|
|
387
|
+
1.0
|
|
388
|
+
);
|
|
389
|
+
if (factor !== 1.0) {
|
|
390
|
+
for (let i = 1; i < record.length; i++) {
|
|
391
|
+
const prev = dayjs(record[i - 1].time);
|
|
392
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
393
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return record;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleEverythingHooks(record, meta) {
|
|
401
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
402
|
+
let events = record;
|
|
403
|
+
if (!events.length) return record;
|
|
404
|
+
|
|
405
|
+
// ── SUPERPROP STAMPING ──────────────────────────
|
|
406
|
+
// Stamp superProps from profile so they are consistent per user.
|
|
407
|
+
if (meta && meta.profile) {
|
|
408
|
+
const p = meta.profile;
|
|
409
|
+
events.forEach(e => {
|
|
410
|
+
if (p.Platform) e.Platform = p.Platform;
|
|
411
|
+
if (p.workout_type) e.workout_type = p.workout_type;
|
|
412
|
+
if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
|
|
417
|
+
// calories_burned 1.3x. No flag — analyst breaks down by HOD.
|
|
418
|
+
events.forEach(e => {
|
|
419
|
+
if (e.event === "workout completed") {
|
|
420
|
+
const hour = new Date(e.time).getUTCHours();
|
|
421
|
+
if (hour >= MORNING_HOUR_START && hour < MORNING_HOUR_END && e.calories_burned) {
|
|
422
|
+
e.calories_burned = Math.floor(e.calories_burned * MORNING_CALORIE_MULT);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
|
|
428
|
+
// After day 35, ~40% of workouts switch to ai_assisted coaching,
|
|
429
|
+
// then ai_assisted workouts get 1.2x duration.
|
|
430
|
+
const AI_LAUNCH = datasetStart.add(AI_LAUNCH_DAY, "days");
|
|
431
|
+
events.forEach(e => {
|
|
432
|
+
if ((e.event === "workout completed" || e.event === "workout planned") &&
|
|
433
|
+
dayjs(e.time).isAfter(AI_LAUNCH)) {
|
|
434
|
+
// Adopt ai_assisted for ~40% of post-launch workouts
|
|
435
|
+
if (chance.bool({ likelihood: AI_ADOPTION_LIKELIHOOD })) {
|
|
436
|
+
e.coaching_mode = "ai_assisted";
|
|
437
|
+
}
|
|
438
|
+
// AI-assisted workouts get 1.2x duration
|
|
439
|
+
if (e.coaching_mode === "ai_assisted") {
|
|
440
|
+
if (e.duration_minutes) {
|
|
441
|
+
e.duration_minutes = Math.floor(e.duration_minutes * AI_DURATION_MULT);
|
|
442
|
+
}
|
|
443
|
+
if (e.planned_duration_minutes) {
|
|
444
|
+
e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * AI_DURATION_MULT);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ── GROUP CHALLENGES ADOPTION ────────────────────
|
|
451
|
+
// After day 55, ~30% of challenge events switch to group mode.
|
|
452
|
+
const GROUP_LAUNCH = datasetStart.add(GROUP_LAUNCH_DAY, "days");
|
|
453
|
+
events.forEach(e => {
|
|
454
|
+
if ((e.event === "challenge joined" || e.event === "workout completed") &&
|
|
455
|
+
dayjs(e.time).isAfter(GROUP_LAUNCH) &&
|
|
456
|
+
chance.bool({ likelihood: GROUP_ADOPTION_LIKELIHOOD })) {
|
|
457
|
+
e.challenge_mode = "group";
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
|
|
462
|
+
// Free/monthly-tier users drop ~30% of "progress checked"
|
|
463
|
+
// (last step of Workout Loop funnel) to simulate lower conversion.
|
|
464
|
+
if (meta && meta.profile) {
|
|
465
|
+
const tier = meta.profile.subscription_tier;
|
|
466
|
+
if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD })) {
|
|
467
|
+
record = record.filter(e => e.event !== "progress checked");
|
|
468
|
+
events = record;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── HOOK 3: STREAK RETENTION ─────────────────────
|
|
473
|
+
// Users with >=2 workouts get streak_days updated and
|
|
474
|
+
// cloned achievement events. Achievements scale super-
|
|
475
|
+
// linearly: 1 per workout for workouts 2-4, then 4 per
|
|
476
|
+
// workout beyond that.
|
|
477
|
+
const workoutEvents = events.filter(e => e.event === "workout completed");
|
|
478
|
+
if (workoutEvents.length >= STREAK_MIN_WORKOUTS) {
|
|
479
|
+
// Update profile streak_days via a profile update event
|
|
480
|
+
if (meta && meta.profile) {
|
|
481
|
+
meta.profile.streak_days = workoutEvents.length;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Super-linear achievement scaling:
|
|
485
|
+
// workouts 2-4: 1 achievement each
|
|
486
|
+
// workouts 5+: 4 achievements each
|
|
487
|
+
const templateAchievement = events.find(e => e.event === "achievement unlocked");
|
|
488
|
+
if (templateAchievement) {
|
|
489
|
+
let achievementCount = Math.min(workoutEvents.length - 1, STREAK_LINEAR_CAP); // 1 each for workouts 2-4
|
|
490
|
+
if (workoutEvents.length > 4) {
|
|
491
|
+
achievementCount += (workoutEvents.length - 4) * STREAK_SUPER_LINEAR_MULT; // 4 each for workouts 5+
|
|
492
|
+
}
|
|
493
|
+
for (let a = 0; a < achievementCount; a++) {
|
|
494
|
+
const srcIdx = Math.min(a, workoutEvents.length - 1);
|
|
495
|
+
const sourceEvent = workoutEvents[srcIdx];
|
|
496
|
+
events.push({
|
|
497
|
+
...templateAchievement,
|
|
498
|
+
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
499
|
+
user_id: sourceEvent.user_id,
|
|
500
|
+
achievement_type: "streak_milestone",
|
|
501
|
+
streak_days_at_unlock: a + 2,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
|
|
508
|
+
// Users with >=3 friend_added events get 1.5x challenge completions.
|
|
509
|
+
const friendCount = events.filter(e => e.event === "friend added").length;
|
|
510
|
+
if (friendCount >= SOCIAL_FRIEND_THRESHOLD) {
|
|
511
|
+
const templateChallenge = events.find(e => e.event === "challenge completed");
|
|
512
|
+
if (templateChallenge) {
|
|
513
|
+
const challengeCompletions = events.filter(e => e.event === "challenge completed");
|
|
514
|
+
const extraCount = Math.max(1, Math.floor(challengeCompletions.length * SOCIAL_CHALLENGE_CLONE_FACTOR));
|
|
515
|
+
for (let i = 0; i < extraCount; i++) {
|
|
516
|
+
const source = challengeCompletions[i % challengeCompletions.length];
|
|
517
|
+
events.push({
|
|
518
|
+
...templateChallenge,
|
|
519
|
+
time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
520
|
+
user_id: source.user_id,
|
|
521
|
+
challenge_type: source.challenge_type,
|
|
522
|
+
completion_pct: chance.integer({ min: 80, max: 100 }),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
|
|
529
|
+
// Resolver segment users with <30 events lose 70% after day 14.
|
|
530
|
+
if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < RESOLVER_EVENT_THRESHOLD) {
|
|
531
|
+
const CHURN_CLIFF = datasetStart.add(RESOLVER_CLIFF_DAYS, "days");
|
|
532
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
533
|
+
const eventTime = dayjs(events[i].time);
|
|
534
|
+
if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: RESOLVER_DROP_LIKELIHOOD })) {
|
|
535
|
+
events.splice(i, 1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
|
|
541
|
+
const hasCoachSessions = events.some(e => e.event === "coach session");
|
|
542
|
+
if (hasCoachSessions) {
|
|
543
|
+
events.forEach(e => {
|
|
544
|
+
if (e.event === "coach session") {
|
|
545
|
+
e.satisfaction_score = chance.floating({ min: COACH_SESSION_SATISFACTION_MIN, max: COACH_SESSION_SATISFACTION_MAX, fixed: 1 });
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
|
|
551
|
+
// Sweet 12-14 workouts → +35% on workout duration_minutes (peak
|
|
552
|
+
// progression). Over 15+ → drop 65% of post-day-30 non-workout
|
|
553
|
+
// events (overtraining → churn). Workout events are preserved
|
|
554
|
+
// so the bucket categorization stays consistent.
|
|
555
|
+
const workoutCount = events.filter(e => e.event === "workout completed").length;
|
|
556
|
+
if (workoutCount >= WORKOUT_SWEET_MIN && workoutCount <= WORKOUT_SWEET_MAX) {
|
|
557
|
+
events.forEach(e => {
|
|
558
|
+
if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
|
|
559
|
+
e.duration_minutes = Math.round(e.duration_minutes * WORKOUT_DURATION_BOOST);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
} else if (workoutCount >= WORKOUT_OVER_THRESHOLD) {
|
|
563
|
+
const day30 = datasetStart.add(WORKOUT_OVER_CUTOFF_DAYS, "days");
|
|
564
|
+
const preserveEvents = new Set(["workout completed", "progress checked"]);
|
|
565
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
566
|
+
if (!preserveEvents.has(events[i].event) &&
|
|
567
|
+
dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: WORKOUT_OVER_DROP_LIKELIHOOD })) {
|
|
568
|
+
events.splice(i, 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return record;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── CONFIG ──
|
|
313
577
|
/** @type {Config} */
|
|
314
578
|
const config = {
|
|
315
579
|
version: 2,
|
|
316
|
-
token,
|
|
317
580
|
seed: SEED,
|
|
318
|
-
datasetStart:
|
|
319
|
-
datasetEnd:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
numUsers: num_users,
|
|
323
|
-
hasAnonIds: true,
|
|
324
|
-
avgDevicePerUser: 3,
|
|
325
|
-
hasSessionIds: true,
|
|
581
|
+
datasetStart: DATASET_START,
|
|
582
|
+
datasetEnd: DATASET_END,
|
|
583
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
584
|
+
numUsers: NUM_USERS,
|
|
326
585
|
format: "json",
|
|
327
586
|
gzip: true,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
587
|
+
credentials: {
|
|
588
|
+
token,
|
|
589
|
+
},
|
|
590
|
+
switches: {
|
|
591
|
+
hasSessionIds: true,
|
|
592
|
+
alsoInferFunnels: false,
|
|
593
|
+
hasLocation: true,
|
|
594
|
+
hasAndroidDevices: true,
|
|
595
|
+
hasIOSDevices: true,
|
|
596
|
+
hasDesktopDevices: false,
|
|
597
|
+
hasBrowser: false,
|
|
598
|
+
hasCampaigns: false,
|
|
599
|
+
isAnonymous: false,
|
|
600
|
+
hasAdSpend: false,
|
|
601
|
+
hasAvatar: true,
|
|
602
|
+
},
|
|
603
|
+
identity: {
|
|
604
|
+
avgDevicePerUser: 3,
|
|
605
|
+
},
|
|
338
606
|
concurrency: 1,
|
|
339
607
|
writeToDisk: false,
|
|
340
608
|
scdProps: {
|
|
@@ -362,6 +630,7 @@ const config = {
|
|
|
362
630
|
{
|
|
363
631
|
event: "workout completed",
|
|
364
632
|
weight: 8,
|
|
633
|
+
isStrictEvent: false,
|
|
365
634
|
properties: {
|
|
366
635
|
duration_minutes: u.weighNumRange(10, 90, 0.5, 40),
|
|
367
636
|
calories_burned: u.weighNumRange(50, 800, 0.4, 300),
|
|
@@ -374,6 +643,7 @@ const config = {
|
|
|
374
643
|
{
|
|
375
644
|
event: "workout planned",
|
|
376
645
|
weight: 6,
|
|
646
|
+
isStrictEvent: false,
|
|
377
647
|
properties: {
|
|
378
648
|
planned_duration_minutes: u.weighNumRange(15, 90, 0.5, 45),
|
|
379
649
|
day_of_week: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
|
|
@@ -393,6 +663,7 @@ const config = {
|
|
|
393
663
|
{
|
|
394
664
|
event: "challenge joined",
|
|
395
665
|
weight: 3,
|
|
666
|
+
isStrictEvent: false,
|
|
396
667
|
properties: {
|
|
397
668
|
challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
|
|
398
669
|
duration_days: [7, 14, 21, 30],
|
|
@@ -403,6 +674,7 @@ const config = {
|
|
|
403
674
|
{
|
|
404
675
|
event: "challenge completed",
|
|
405
676
|
weight: 2,
|
|
677
|
+
isStrictEvent: false,
|
|
406
678
|
properties: {
|
|
407
679
|
challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
|
|
408
680
|
final_rank: u.weighNumRange(1, 50, 0.3, 10),
|
|
@@ -413,6 +685,7 @@ const config = {
|
|
|
413
685
|
{
|
|
414
686
|
event: "achievement unlocked",
|
|
415
687
|
weight: 2,
|
|
688
|
+
isStrictEvent: false,
|
|
416
689
|
properties: {
|
|
417
690
|
achievement_type: ["streak_milestone", "weight_goal", "distance_record", "calories_target", "social_champion", "first_workout"],
|
|
418
691
|
streak_days_at_unlock: u.weighNumRange(1, 100, 0.3, 10),
|
|
@@ -421,6 +694,7 @@ const config = {
|
|
|
421
694
|
{
|
|
422
695
|
event: "friend added",
|
|
423
696
|
weight: 2,
|
|
697
|
+
isStrictEvent: false,
|
|
424
698
|
properties: {
|
|
425
699
|
source: ["search", "contacts", "challenge", "suggestion", "qr_code"],
|
|
426
700
|
},
|
|
@@ -436,6 +710,7 @@ const config = {
|
|
|
436
710
|
{
|
|
437
711
|
event: "progress checked",
|
|
438
712
|
weight: 5,
|
|
713
|
+
isStrictEvent: false,
|
|
439
714
|
properties: {
|
|
440
715
|
metric_viewed: ["weight", "body_fat", "steps", "calories_burned", "workout_count", "streaks"],
|
|
441
716
|
trend_direction: ["improving", "improving", "stable", "declining"],
|
|
@@ -445,6 +720,7 @@ const config = {
|
|
|
445
720
|
{
|
|
446
721
|
event: "coach session",
|
|
447
722
|
weight: 3,
|
|
723
|
+
isStrictEvent: false,
|
|
448
724
|
properties: {
|
|
449
725
|
session_type: ["live_video", "chat", "plan_review", "form_check"],
|
|
450
726
|
duration_minutes: u.weighNumRange(10, 60, 0.5, 30),
|
|
@@ -497,6 +773,7 @@ const config = {
|
|
|
497
773
|
{
|
|
498
774
|
event: "profile updated",
|
|
499
775
|
weight: 2,
|
|
776
|
+
isStrictEvent: false,
|
|
500
777
|
properties: {
|
|
501
778
|
field_updated: ["weight", "height", "goal", "avatar", "workout_preferences", "notification_settings"],
|
|
502
779
|
},
|
|
@@ -531,6 +808,7 @@ const config = {
|
|
|
531
808
|
order: "sequential",
|
|
532
809
|
timeToConvert: 48,
|
|
533
810
|
weight: 5,
|
|
811
|
+
reentry: true,
|
|
534
812
|
},
|
|
535
813
|
{
|
|
536
814
|
name: "Social Engagement",
|
|
@@ -646,234 +924,10 @@ const config = {
|
|
|
646
924
|
reactivationChance: 0.02,
|
|
647
925
|
},
|
|
648
926
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
// Also assign subscription_tier by segment (replacing deprecated subscription feature).
|
|
654
|
-
if (type === "user") {
|
|
655
|
-
if (record.segment === "coach") {
|
|
656
|
-
record.total_workouts = chance.integer({ min: 200, max: 500 });
|
|
657
|
-
record.streak_days = chance.integer({ min: 60, max: 365 });
|
|
658
|
-
}
|
|
659
|
-
// Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
|
|
660
|
-
if (record.segment === "athlete") {
|
|
661
|
-
record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
|
|
662
|
-
} else if (record.segment === "coach") {
|
|
663
|
-
record.subscription_tier = chance.pickone(["annual", "family", "family"]);
|
|
664
|
-
} else if (record.segment === "social") {
|
|
665
|
-
record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
|
|
666
|
-
} else if (record.segment === "resolver") {
|
|
667
|
-
record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
|
|
668
|
-
} else {
|
|
669
|
-
record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// HOOK 9 (T2C): WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
|
|
674
|
-
// Annual subscribers complete the Workout Loop funnel 1.3x faster
|
|
675
|
-
// (factor 0.77); Free users 1.25x slower (factor 1.25).
|
|
676
|
-
if (type === "funnel-post") {
|
|
677
|
-
const tier = meta?.profile?.subscription_tier;
|
|
678
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
679
|
-
const factor = (
|
|
680
|
-
tier === "annual" || tier === "family" ? 0.77 :
|
|
681
|
-
tier === "free" ? 1.25 :
|
|
682
|
-
1.0
|
|
683
|
-
);
|
|
684
|
-
if (factor !== 1.0) {
|
|
685
|
-
for (let i = 1; i < record.length; i++) {
|
|
686
|
-
const prev = dayjs(record[i - 1].time);
|
|
687
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
688
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// (HOOK 1 & 2 moved to everything hook — temporal and hour checks
|
|
695
|
-
// must run after bunchIntoSessions redistributes timestamps)
|
|
696
|
-
|
|
697
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
698
|
-
if (type === "everything") {
|
|
699
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
700
|
-
let events = record;
|
|
701
|
-
if (!events.length) return record;
|
|
702
|
-
|
|
703
|
-
// ── SUPERPROP STAMPING ──────────────────────────
|
|
704
|
-
// Stamp superProps from profile so they are consistent per user.
|
|
705
|
-
if (meta && meta.profile) {
|
|
706
|
-
const p = meta.profile;
|
|
707
|
-
events.forEach(e => {
|
|
708
|
-
if (p.Platform) e.Platform = p.Platform;
|
|
709
|
-
if (p.workout_type) e.workout_type = p.workout_type;
|
|
710
|
-
if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
|
|
715
|
-
// calories_burned 1.3x. No flag — analyst breaks down by HOD.
|
|
716
|
-
events.forEach(e => {
|
|
717
|
-
if (e.event === "workout completed") {
|
|
718
|
-
const hour = new Date(e.time).getUTCHours();
|
|
719
|
-
if (hour >= 5 && hour < 9 && e.calories_burned) {
|
|
720
|
-
e.calories_burned = Math.floor(e.calories_burned * 1.3);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
// ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
|
|
726
|
-
// After day 35, ~40% of workouts switch to ai_assisted coaching,
|
|
727
|
-
// then ai_assisted workouts get 1.2x duration.
|
|
728
|
-
// (Replaces deprecated 'features' config; temporal check in everything per L1)
|
|
729
|
-
const AI_LAUNCH = datasetStart.add(35, "days");
|
|
730
|
-
events.forEach(e => {
|
|
731
|
-
if ((e.event === "workout completed" || e.event === "workout planned") &&
|
|
732
|
-
dayjs(e.time).isAfter(AI_LAUNCH)) {
|
|
733
|
-
// Adopt ai_assisted for ~40% of post-launch workouts
|
|
734
|
-
if (chance.bool({ likelihood: 40 })) {
|
|
735
|
-
e.coaching_mode = "ai_assisted";
|
|
736
|
-
}
|
|
737
|
-
// AI-assisted workouts get 1.2x duration
|
|
738
|
-
if (e.coaching_mode === "ai_assisted") {
|
|
739
|
-
if (e.duration_minutes) {
|
|
740
|
-
e.duration_minutes = Math.floor(e.duration_minutes * 1.2);
|
|
741
|
-
}
|
|
742
|
-
if (e.planned_duration_minutes) {
|
|
743
|
-
e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * 1.2);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
// ── GROUP CHALLENGES ADOPTION ────────────────────
|
|
750
|
-
// After day 55, ~30% of challenge events switch to group mode.
|
|
751
|
-
// (Replaces deprecated 'features' config)
|
|
752
|
-
const GROUP_LAUNCH = datasetStart.add(55, "days");
|
|
753
|
-
events.forEach(e => {
|
|
754
|
-
if ((e.event === "challenge joined" || e.event === "workout completed") &&
|
|
755
|
-
dayjs(e.time).isAfter(GROUP_LAUNCH) &&
|
|
756
|
-
chance.bool({ likelihood: 30 })) {
|
|
757
|
-
e.challenge_mode = "group";
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
// ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
|
|
762
|
-
// Free/monthly-tier users drop ~30% of "progress checked"
|
|
763
|
-
// (last step of Workout Loop funnel) to simulate lower conversion.
|
|
764
|
-
if (meta && meta.profile) {
|
|
765
|
-
const tier = meta.profile.subscription_tier;
|
|
766
|
-
if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: 30 })) {
|
|
767
|
-
record = record.filter(e => e.event !== "progress checked");
|
|
768
|
-
events = record;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// ── HOOK 3: STREAK RETENTION ─────────────────────
|
|
773
|
-
// Users with >=2 workouts get streak_days updated and
|
|
774
|
-
// cloned achievement events. Achievements scale super-
|
|
775
|
-
// linearly: 1 per workout for workouts 2-4, then 4 per
|
|
776
|
-
// workout beyond that. This amplifies the gap between
|
|
777
|
-
// high-workout segments (athlete/coach) and casual users.
|
|
778
|
-
const workoutEvents = events.filter(e => e.event === "workout completed");
|
|
779
|
-
if (workoutEvents.length >= 2) {
|
|
780
|
-
// Update profile streak_days via a profile update event
|
|
781
|
-
if (meta && meta.profile) {
|
|
782
|
-
meta.profile.streak_days = workoutEvents.length;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Super-linear achievement scaling:
|
|
786
|
-
// workouts 2-4: 1 achievement each
|
|
787
|
-
// workouts 5+: 4 achievements each
|
|
788
|
-
const templateAchievement = events.find(e => e.event === "achievement unlocked");
|
|
789
|
-
if (templateAchievement) {
|
|
790
|
-
let achievementCount = Math.min(workoutEvents.length - 1, 3); // 1 each for workouts 2-4
|
|
791
|
-
if (workoutEvents.length > 4) {
|
|
792
|
-
achievementCount += (workoutEvents.length - 4) * 4; // 4 each for workouts 5+
|
|
793
|
-
}
|
|
794
|
-
for (let a = 0; a < achievementCount; a++) {
|
|
795
|
-
const srcIdx = Math.min(a, workoutEvents.length - 1);
|
|
796
|
-
const sourceEvent = workoutEvents[srcIdx];
|
|
797
|
-
events.push({
|
|
798
|
-
...templateAchievement,
|
|
799
|
-
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
800
|
-
user_id: sourceEvent.user_id,
|
|
801
|
-
achievement_type: "streak_milestone",
|
|
802
|
-
streak_days_at_unlock: a + 2,
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
|
|
809
|
-
// Users with >=3 friend_added events get 1.5x challenge completions.
|
|
810
|
-
const friendCount = events.filter(e => e.event === "friend added").length;
|
|
811
|
-
if (friendCount >= 3) {
|
|
812
|
-
const templateChallenge = events.find(e => e.event === "challenge completed");
|
|
813
|
-
if (templateChallenge) {
|
|
814
|
-
const challengeCompletions = events.filter(e => e.event === "challenge completed");
|
|
815
|
-
const extraCount = Math.max(1, Math.floor(challengeCompletions.length * 0.5));
|
|
816
|
-
for (let i = 0; i < extraCount; i++) {
|
|
817
|
-
const source = challengeCompletions[i % challengeCompletions.length];
|
|
818
|
-
events.push({
|
|
819
|
-
...templateChallenge,
|
|
820
|
-
time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
821
|
-
user_id: source.user_id,
|
|
822
|
-
challenge_type: source.challenge_type,
|
|
823
|
-
completion_pct: chance.integer({ min: 80, max: 100 }),
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
|
|
830
|
-
// Resolver segment users with <30 events lose 70% after day 14.
|
|
831
|
-
if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 30) {
|
|
832
|
-
const CHURN_CLIFF = datasetStart.add(14, "days");
|
|
833
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
834
|
-
const eventTime = dayjs(events[i].time);
|
|
835
|
-
if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: 70 })) {
|
|
836
|
-
events.splice(i, 1);
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
|
|
842
|
-
const hasCoachSessions = events.some(e => e.event === "coach session");
|
|
843
|
-
if (hasCoachSessions) {
|
|
844
|
-
events.forEach(e => {
|
|
845
|
-
if (e.event === "coach session") {
|
|
846
|
-
e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
|
|
847
|
-
}
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
|
|
852
|
-
// Sweet 12-14 workouts → +35% on workout duration_minutes (peak
|
|
853
|
-
// progression). Over 15+ → drop 65% of post-day-30 non-workout
|
|
854
|
-
// events (overtraining → churn). Workout events are preserved
|
|
855
|
-
// so the bucket categorization stays consistent.
|
|
856
|
-
const workoutCount = events.filter(e => e.event === "workout completed").length;
|
|
857
|
-
if (workoutCount >= 12 && workoutCount <= 14) {
|
|
858
|
-
events.forEach(e => {
|
|
859
|
-
if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
|
|
860
|
-
e.duration_minutes = Math.round(e.duration_minutes * 1.35);
|
|
861
|
-
}
|
|
862
|
-
});
|
|
863
|
-
} else if (workoutCount >= 15) {
|
|
864
|
-
const day30 = datasetStart.add(30, "days");
|
|
865
|
-
const preserveEvents = new Set(["workout completed", "progress checked"]);
|
|
866
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
867
|
-
if (!preserveEvents.has(events[i].event) &&
|
|
868
|
-
dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 65 })) {
|
|
869
|
-
events.splice(i, 1);
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
return record;
|
|
875
|
-
}
|
|
876
|
-
|
|
927
|
+
hook(record, type, meta) {
|
|
928
|
+
if (type === "user") return handleUserHooks(record);
|
|
929
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
930
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
877
931
|
return record;
|
|
878
932
|
},
|
|
879
933
|
};
|