@ak--47/dungeon-master 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dungeons/technical/ad-spend.js +15 -4
- package/dungeons/technical/anonymous-users.js +15 -5
- package/dungeons/technical/array-of-object-lookup.js +21 -5
- package/dungeons/technical/experiments.js +23 -5
- package/dungeons/technical/foobar.js +28 -12
- package/dungeons/technical/group-analytics.js +15 -5
- package/dungeons/technical/mirror-strategies.js +15 -5
- package/dungeons/technical/nested-objects.js +15 -4
- package/dungeons/technical/retention-cadence.js +13 -7
- package/dungeons/technical/sanity.js +25 -6
- package/dungeons/technical/scale-test.js +15 -4
- package/dungeons/technical/scd.js +24 -5
- package/dungeons/technical/simple.js +20 -5
- package/dungeons/technical/simplest.js +23 -6
- package/dungeons/technical/text-generation.js +22 -6
- package/dungeons/user/.gitkeep +0 -0
- package/dungeons/vertical/community-schema.json +1 -1
- package/dungeons/vertical/community.js +49 -28
- package/dungeons/vertical/devtools-schema.json +1 -1
- package/dungeons/vertical/devtools.js +50 -29
- package/dungeons/vertical/ecommerce.js +23 -6
- package/dungeons/vertical/education-schema.json +1 -1
- package/dungeons/vertical/education.js +29 -19
- package/dungeons/vertical/fintech-schema.json +1 -1
- package/dungeons/vertical/fintech.js +27 -8
- package/dungeons/vertical/fitness-schema.json +1 -1
- package/dungeons/vertical/fitness.js +48 -22
- package/dungeons/vertical/food-delivery-schema.json +1 -1
- package/dungeons/vertical/food-delivery.js +51 -12
- package/dungeons/vertical/gaming-schema.json +1 -1
- package/dungeons/vertical/gaming.js +41 -7
- package/dungeons/vertical/healthcare-schema.json +1 -1
- package/dungeons/vertical/healthcare.js +69 -40
- package/dungeons/vertical/insurance-application-schema.json +1 -1
- package/dungeons/vertical/insurance-application.js +26 -8
- package/dungeons/vertical/logistics-schema.json +1 -1
- package/dungeons/vertical/logistics.js +69 -41
- package/dungeons/vertical/marketplace-schema.json +1 -1
- package/dungeons/vertical/marketplace.js +44 -19
- package/dungeons/vertical/media.js +56 -15
- package/dungeons/vertical/rpg-schema.json +1 -1
- package/dungeons/vertical/rpg.js +38 -8
- package/dungeons/vertical/sass.js +24 -7
- package/dungeons/vertical/social.js +23 -7
- package/dungeons/vertical/travel-schema.json +1 -1
- package/dungeons/vertical/travel.js +50 -19
- package/index.js +3 -0
- package/lib/core/config-validator.js +13 -2
- package/lib/generators/events.js +3 -5
- package/lib/orchestrators/mixpanel-sender.js +64 -3
- package/lib/orchestrators/user-loop.js +20 -0
- package/lib/utils/utils.js +129 -9
- package/package.json +2 -2
- package/types.d.ts +5 -3
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "dm4-fitness";
|
|
3
|
+
const num_days = 100;
|
|
4
|
+
const num_users = 5_000;
|
|
5
|
+
const avg_events_per_user = 120;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
+
|
|
1
11
|
import dayjs from "dayjs";
|
|
2
12
|
import utc from "dayjs/plugin/utc.js";
|
|
3
13
|
import "dotenv/config";
|
|
4
14
|
import * as u from "../../lib/utils/utils.js";
|
|
5
15
|
|
|
6
|
-
const SEED = "dm4-fitness";
|
|
7
16
|
dayjs.extend(utc);
|
|
8
17
|
const chance = u.initChance(SEED);
|
|
9
|
-
const num_users = 5_000;
|
|
10
|
-
const days = 100;
|
|
11
18
|
const NOW = dayjs();
|
|
12
|
-
const DATASET_START = NOW.subtract(
|
|
19
|
+
const DATASET_START = NOW.subtract(num_days, "days");
|
|
13
20
|
|
|
14
21
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
15
22
|
|
|
@@ -219,12 +226,12 @@ const DATASET_START = NOW.subtract(days, "days");
|
|
|
219
226
|
* consistency to build credibility with their clients.
|
|
220
227
|
*
|
|
221
228
|
* ───────────────────────────────────────────────────────────────
|
|
222
|
-
* 8. ANNUAL SUBSCRIBER WORKOUT FUNNEL LIFT (
|
|
229
|
+
* 8. ANNUAL SUBSCRIBER WORKOUT FUNNEL LIFT (everything hook)
|
|
223
230
|
* ───────────────────────────────────────────────────────────────
|
|
224
231
|
*
|
|
225
|
-
* PATTERN:
|
|
226
|
-
*
|
|
227
|
-
* Annual
|
|
232
|
+
* PATTERN: Free/monthly-tier users lose ~30% of "progress checked"
|
|
233
|
+
* events (last step of the Workout Loop funnel), simulating lower
|
|
234
|
+
* follow-through. Annual/family subscribers retain all events.
|
|
228
235
|
*
|
|
229
236
|
* HOW TO FIND IT IN MIXPANEL:
|
|
230
237
|
*
|
|
@@ -255,10 +262,10 @@ const DATASET_START = NOW.subtract(days, "days");
|
|
|
255
262
|
|
|
256
263
|
/** @type {Config} */
|
|
257
264
|
const config = {
|
|
258
|
-
token
|
|
265
|
+
token,
|
|
259
266
|
seed: SEED,
|
|
260
|
-
numDays:
|
|
261
|
-
numEvents: num_users *
|
|
267
|
+
numDays: num_days,
|
|
268
|
+
numEvents: num_users * avg_events_per_user,
|
|
262
269
|
numUsers: num_users,
|
|
263
270
|
hasAnonIds: false,
|
|
264
271
|
hasSessionIds: true,
|
|
@@ -500,7 +507,7 @@ const config = {
|
|
|
500
507
|
|
|
501
508
|
// ── SuperProps ──────────────────────────────────────────
|
|
502
509
|
superProps: {
|
|
503
|
-
|
|
510
|
+
Platform: ["ios", "ios", "android"],
|
|
504
511
|
workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
|
|
505
512
|
},
|
|
506
513
|
|
|
@@ -512,6 +519,8 @@ const config = {
|
|
|
512
519
|
total_workouts: u.weighNumRange(0, 0, 0.5),
|
|
513
520
|
preferred_workout: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
|
|
514
521
|
goal: ["weight_loss", "muscle_gain", "endurance", "flexibility", "general_health"],
|
|
522
|
+
Platform: ["ios", "ios", "android"],
|
|
523
|
+
workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
|
|
515
524
|
},
|
|
516
525
|
|
|
517
526
|
// ── Personas ──────────────────────────────────
|
|
@@ -679,16 +688,9 @@ const config = {
|
|
|
679
688
|
}
|
|
680
689
|
|
|
681
690
|
// ── HOOK 8: ANNUAL SUBSCRIBER WORKOUT FUNNEL LIFT (funnel-pre) ─
|
|
682
|
-
//
|
|
691
|
+
// (conversionRate filtering moved to everything hook)
|
|
683
692
|
if (type === "funnel-pre") {
|
|
684
|
-
|
|
685
|
-
const tier = meta.profile.subscription_tier;
|
|
686
|
-
if (tier === "annual" || tier === "family") {
|
|
687
|
-
record.conversionRate = Math.min(record.conversionRate * 1.4, 95);
|
|
688
|
-
} else if (tier === "monthly") {
|
|
689
|
-
record.conversionRate = Math.min(record.conversionRate * 1.15, 90);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
693
|
+
// no-op: conversion filtering handled via event dropping in everything hook
|
|
692
694
|
}
|
|
693
695
|
|
|
694
696
|
// ── HOOK 1: MORNING WORKOUT BOOST (event) ────────────
|
|
@@ -722,9 +724,33 @@ const config = {
|
|
|
722
724
|
|
|
723
725
|
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
724
726
|
if (type === "everything") {
|
|
725
|
-
|
|
727
|
+
let events = record;
|
|
726
728
|
if (!events.length) return record;
|
|
727
729
|
|
|
730
|
+
// ── SUPERPROP STAMPING ──────────────────────────
|
|
731
|
+
// Stamp superProps from profile so they are consistent per user.
|
|
732
|
+
if (meta && meta.profile) {
|
|
733
|
+
const p = meta.profile;
|
|
734
|
+
events.forEach(e => {
|
|
735
|
+
if (p.Platform) e.Platform = p.Platform;
|
|
736
|
+
if (p.workout_type) e.workout_type = p.workout_type;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
|
|
741
|
+
// Free/monthly-tier users drop ~30% of "progress checked"
|
|
742
|
+
// (last step of Workout Loop funnel) to simulate lower conversion.
|
|
743
|
+
if (meta && meta.profile) {
|
|
744
|
+
const tier = meta.profile.subscription_tier;
|
|
745
|
+
if (tier !== "annual" && tier !== "family") {
|
|
746
|
+
record = record.filter(e => {
|
|
747
|
+
if (e.event === "progress checked" && chance.bool({ likelihood: 30 })) return false;
|
|
748
|
+
return true;
|
|
749
|
+
});
|
|
750
|
+
events = record;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
728
754
|
// ── HOOK 3: STREAK RETENTION ─────────────────────
|
|
729
755
|
// Users with >10 workouts get streak_days updated and
|
|
730
756
|
// cloned achievement events for milestones.
|
|
@@ -36600,7 +36600,7 @@
|
|
|
36600
36600
|
}
|
|
36601
36601
|
],
|
|
36602
36602
|
"superProps": {
|
|
36603
|
-
"
|
|
36603
|
+
"Platform": [
|
|
36604
36604
|
"iOS",
|
|
36605
36605
|
"Android",
|
|
36606
36606
|
"Web"
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "harness-food";
|
|
3
|
+
const num_days = 100;
|
|
4
|
+
const num_users = 5_000;
|
|
5
|
+
const avg_events_per_user = 120;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
+
|
|
1
11
|
import dayjs from "dayjs";
|
|
2
12
|
import utc from "dayjs/plugin/utc.js";
|
|
3
13
|
import "dotenv/config";
|
|
4
14
|
import * as u from "../../lib/utils/utils.js";
|
|
5
15
|
import * as v from "ak-tools";
|
|
6
16
|
|
|
7
|
-
const SEED = "harness-food";
|
|
8
17
|
dayjs.extend(utc);
|
|
9
18
|
const chance = u.initChance(SEED);
|
|
10
|
-
const num_users = 5_000;
|
|
11
|
-
const days = 100;
|
|
12
19
|
|
|
13
20
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
14
21
|
|
|
@@ -156,10 +163,10 @@ const couponCodes = v.range(1, 51).map(n => `QUICK${v.uid(5).toUpperCase()}`);
|
|
|
156
163
|
|
|
157
164
|
/** @type {Config} */
|
|
158
165
|
const config = {
|
|
159
|
-
token
|
|
166
|
+
token,
|
|
160
167
|
seed: SEED,
|
|
161
|
-
numDays:
|
|
162
|
-
numEvents: num_users *
|
|
168
|
+
numDays: num_days,
|
|
169
|
+
numEvents: num_users * avg_events_per_user,
|
|
163
170
|
numUsers: num_users,
|
|
164
171
|
hasAnonIds: false,
|
|
165
172
|
hasSessionIds: true,
|
|
@@ -379,6 +386,9 @@ const config = {
|
|
|
379
386
|
"order_id": orderIds,
|
|
380
387
|
"actual_delivery_mins": u.weighNumRange(12, 90, 1.0, 40),
|
|
381
388
|
"on_time": [false, false, false, true, true, true, true, true, true, true],
|
|
389
|
+
"lunch_rush": [false],
|
|
390
|
+
"dinner_rush": [false],
|
|
391
|
+
"first_order_bonus": [false],
|
|
382
392
|
"trial_retained": [false],
|
|
383
393
|
}
|
|
384
394
|
},
|
|
@@ -459,7 +469,7 @@ const config = {
|
|
|
459
469
|
],
|
|
460
470
|
|
|
461
471
|
superProps: {
|
|
462
|
-
|
|
472
|
+
Platform: ["iOS", "Android", "Web"],
|
|
463
473
|
subscription_tier: ["Free", "Free", "Free", "Free", "QuickBite+"],
|
|
464
474
|
city: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "San Francisco"],
|
|
465
475
|
},
|
|
@@ -480,6 +490,9 @@ const config = {
|
|
|
480
490
|
"favorite_restaurant_count": u.weighNumRange(1, 10),
|
|
481
491
|
"is_high_risk": [false],
|
|
482
492
|
"churn_risk_score": [0],
|
|
493
|
+
"Platform": ["iOS", "Android", "Web"],
|
|
494
|
+
"subscription_tier": ["Free", "Free", "Free", "Free", "QuickBite+"],
|
|
495
|
+
"city": ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "San Francisco"],
|
|
483
496
|
},
|
|
484
497
|
|
|
485
498
|
groupKeys: [
|
|
@@ -522,7 +535,7 @@ const config = {
|
|
|
522
535
|
*/
|
|
523
536
|
hook: function (record, type, meta) {
|
|
524
537
|
const NOW = dayjs();
|
|
525
|
-
const DATASET_START = NOW.subtract(
|
|
538
|
+
const DATASET_START = NOW.subtract(num_days, 'days');
|
|
526
539
|
const RAINY_WEEK_START = DATASET_START.add(20, 'days');
|
|
527
540
|
const RAINY_WEEK_END = DATASET_START.add(27, 'days');
|
|
528
541
|
|
|
@@ -539,13 +552,11 @@ const config = {
|
|
|
539
552
|
|
|
540
553
|
// Lunch rush: 11AM - 1PM
|
|
541
554
|
if (hour >= 11 && hour <= 13) {
|
|
542
|
-
record.conversionRate = record.conversionRate * 1.4;
|
|
543
555
|
record.props.lunch_rush = true;
|
|
544
556
|
record.props.dinner_rush = false;
|
|
545
557
|
}
|
|
546
558
|
// Dinner rush: 5PM - 8PM
|
|
547
559
|
else if (hour >= 17 && hour <= 20) {
|
|
548
|
-
record.conversionRate = record.conversionRate * 1.2;
|
|
549
560
|
record.props.lunch_rush = false;
|
|
550
561
|
record.props.dinner_rush = true;
|
|
551
562
|
} else {
|
|
@@ -568,7 +579,6 @@ const config = {
|
|
|
568
579
|
const userId = meta && meta.user && (meta.user.distinct_id || String(meta.user));
|
|
569
580
|
const isNewUser = userId && userId.charCodeAt(0) % 2 === 0;
|
|
570
581
|
if (isNewUser) {
|
|
571
|
-
record.conversionRate = Math.min(98, record.conversionRate * 1.4);
|
|
572
582
|
record.props.first_order_bonus = true;
|
|
573
583
|
} else {
|
|
574
584
|
record.props.first_order_bonus = false;
|
|
@@ -605,7 +615,7 @@ const config = {
|
|
|
605
615
|
time: midTime,
|
|
606
616
|
user_id: firstEvent.user_id,
|
|
607
617
|
subscription_tier: firstEvent.subscription_tier,
|
|
608
|
-
|
|
618
|
+
Platform: firstEvent.Platform,
|
|
609
619
|
city: firstEvent.city,
|
|
610
620
|
coupon_code: chance.pickone(couponCodes),
|
|
611
621
|
discount_type: chance.pickone(["percent", "flat", "free_delivery"]),
|
|
@@ -673,6 +683,35 @@ const config = {
|
|
|
673
683
|
// ═══════════════════════════════════════════════════════════════════
|
|
674
684
|
if (type === "everything") {
|
|
675
685
|
const userEvents = record;
|
|
686
|
+
const profile = meta.profile;
|
|
687
|
+
|
|
688
|
+
// ── Stamp superProps from profile so they stay consistent per user ──
|
|
689
|
+
if (profile) {
|
|
690
|
+
userEvents.forEach((event) => {
|
|
691
|
+
if (profile.Platform !== undefined) event.Platform = profile.Platform;
|
|
692
|
+
if (profile.subscription_tier !== undefined) event.subscription_tier = profile.subscription_tier;
|
|
693
|
+
if (profile.city !== undefined) event.city = profile.city;
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Bug 2 fix: Conversion filtering for lunch rush + first order ──
|
|
698
|
+
// Hook 1: Non-lunch/dinner-hour events → drop ~30% of final funnel step
|
|
699
|
+
// Hook 8: Returning users (no first_order_bonus) → drop ~30% of final funnel step
|
|
700
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
701
|
+
const event = userEvents[i];
|
|
702
|
+
if (event.event === "order delivered") {
|
|
703
|
+
// Hook 1: if not during lunch or dinner rush, drop 30%
|
|
704
|
+
if (!event.lunch_rush && !event.dinner_rush && chance.bool({ likelihood: 30 })) {
|
|
705
|
+
userEvents.splice(i, 1);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
// Hook 8: returning users (no first_order_bonus) drop 30%
|
|
709
|
+
if (event.first_order_bonus === false && chance.bool({ likelihood: 30 })) {
|
|
710
|
+
userEvents.splice(i, 1);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
676
715
|
|
|
677
716
|
// First pass: identify user patterns
|
|
678
717
|
let isReferralUser = false;
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "my-seed";
|
|
3
|
+
const num_days = 180;
|
|
4
|
+
const num_users = 12_000;
|
|
5
|
+
const avg_events_per_user = 90;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
1
10
|
|
|
2
11
|
import dayjs from "dayjs";
|
|
3
12
|
import utc from "dayjs/plugin/utc.js";
|
|
@@ -5,11 +14,8 @@ import "dotenv/config";
|
|
|
5
14
|
import * as u from "../../lib/utils/utils.js";
|
|
6
15
|
import * as v from "ak-tools";
|
|
7
16
|
|
|
8
|
-
const SEED = "my-seed";
|
|
9
17
|
dayjs.extend(utc);
|
|
10
18
|
const chance = u.initChance(SEED);
|
|
11
|
-
const num_users = 12_000;
|
|
12
|
-
const days = 180;
|
|
13
19
|
|
|
14
20
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
15
21
|
|
|
@@ -154,10 +160,10 @@ const days = 180;
|
|
|
154
160
|
|
|
155
161
|
/** @type {Config} */
|
|
156
162
|
const config = {
|
|
157
|
-
token
|
|
163
|
+
token,
|
|
158
164
|
seed: "i am gamer face",
|
|
159
|
-
numDays:
|
|
160
|
-
numEvents: num_users *
|
|
165
|
+
numDays: num_days,
|
|
166
|
+
numEvents: num_users * avg_events_per_user,
|
|
161
167
|
numUsers: num_users,
|
|
162
168
|
hasAnonIds: false,
|
|
163
169
|
hasSessionIds: false,
|
|
@@ -360,7 +366,7 @@ const config = {
|
|
|
360
366
|
},
|
|
361
367
|
],
|
|
362
368
|
superProps: {
|
|
363
|
-
|
|
369
|
+
Platform: [
|
|
364
370
|
"Mobile",
|
|
365
371
|
"Xbox",
|
|
366
372
|
"Playstation",
|
|
@@ -404,6 +410,26 @@ const config = {
|
|
|
404
410
|
}
|
|
405
411
|
},
|
|
406
412
|
userProps: {
|
|
413
|
+
Platform: [
|
|
414
|
+
"Mobile",
|
|
415
|
+
"Xbox",
|
|
416
|
+
"Playstation",
|
|
417
|
+
"Switch",
|
|
418
|
+
"Web"
|
|
419
|
+
],
|
|
420
|
+
"game mode": u.pickAWinner([
|
|
421
|
+
"Single Player",
|
|
422
|
+
"Multiplayer",
|
|
423
|
+
], 1),
|
|
424
|
+
language: [
|
|
425
|
+
"English",
|
|
426
|
+
"Spanish",
|
|
427
|
+
"French",
|
|
428
|
+
"German",
|
|
429
|
+
"Japanese",
|
|
430
|
+
"Korean",
|
|
431
|
+
"Chinese",
|
|
432
|
+
],
|
|
407
433
|
experiment: [
|
|
408
434
|
"fast leveling",
|
|
409
435
|
"tension economy",
|
|
@@ -511,7 +537,15 @@ const config = {
|
|
|
511
537
|
}
|
|
512
538
|
|
|
513
539
|
if (type === "everything") {
|
|
540
|
+
// Stamp superProps from profile for consistency
|
|
541
|
+
const profile = meta.profile;
|
|
542
|
+
record.forEach(e => {
|
|
543
|
+
e.Platform = profile.Platform;
|
|
544
|
+
e["game mode"] = profile["game mode"];
|
|
545
|
+
e.language = profile.language;
|
|
546
|
+
});
|
|
514
547
|
|
|
548
|
+
return record;
|
|
515
549
|
}
|
|
516
550
|
|
|
517
551
|
return record;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "dm4-healthcare";
|
|
3
|
+
const num_days = 100;
|
|
4
|
+
const num_users = 5_000;
|
|
5
|
+
const avg_events_per_user = 120;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
+
|
|
1
11
|
import dayjs from "dayjs";
|
|
2
12
|
import utc from "dayjs/plugin/utc.js";
|
|
3
13
|
import "dotenv/config";
|
|
4
14
|
import * as u from "../../lib/utils/utils.js";
|
|
5
15
|
import * as v from "ak-tools";
|
|
6
16
|
|
|
7
|
-
const SEED = "dm4-healthcare";
|
|
8
17
|
dayjs.extend(utc);
|
|
9
18
|
const chance = u.initChance(SEED);
|
|
10
|
-
const num_users = 5_000;
|
|
11
|
-
const days = 100;
|
|
12
19
|
const NOW = dayjs();
|
|
13
|
-
const DATASET_START = NOW.subtract(
|
|
20
|
+
const DATASET_START = NOW.subtract(num_days, "days");
|
|
14
21
|
|
|
15
22
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
16
23
|
|
|
@@ -199,22 +206,25 @@ const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
|
|
|
199
206
|
* and experience levels that affect patient matching.
|
|
200
207
|
*
|
|
201
208
|
* ───────────────────────────────────────────────────────────────
|
|
202
|
-
* 8.
|
|
209
|
+
* 8. FREE-TIER CONVERSION DROP (everything hook)
|
|
203
210
|
* ───────────────────────────────────────────────────────────────
|
|
204
211
|
*
|
|
205
|
-
* PATTERN:
|
|
206
|
-
*
|
|
212
|
+
* PATTERN: Free-tier users lose ~30% of "consultation completed"
|
|
213
|
+
* events (last step of the Booking to Consultation funnel).
|
|
214
|
+
* This is implemented via event filtering in the everything hook
|
|
215
|
+
* rather than conversionRate modification in funnel-pre, so the
|
|
216
|
+
* effect is not diluted by organic (non-funnel) events.
|
|
207
217
|
*
|
|
208
218
|
* HOW TO FIND IT IN MIXPANEL:
|
|
209
219
|
*
|
|
210
220
|
* Report 1: Booking Conversion by Tier
|
|
211
221
|
* • Report type: Funnels
|
|
212
222
|
* • Steps: "symptom search" → "appointment booked" → "consultation completed"
|
|
213
|
-
* • Breakdown: "subscription_tier"
|
|
214
|
-
* • Expected:
|
|
223
|
+
* • Breakdown: "subscription_tier"
|
|
224
|
+
* • Expected: free ≈ 28% vs basic/premium ≈ 40% conversion
|
|
215
225
|
*
|
|
216
|
-
* REAL-WORLD ANALOGUE:
|
|
217
|
-
*
|
|
226
|
+
* REAL-WORLD ANALOGUE: Free-tier patients face longer wait times
|
|
227
|
+
* and limited scheduling, reducing completed consultations.
|
|
218
228
|
*
|
|
219
229
|
* ═══════════════════════════════════════════════════════════════
|
|
220
230
|
* EXPECTED METRICS SUMMARY
|
|
@@ -229,15 +239,15 @@ const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
|
|
|
229
239
|
* Chronic Refill Chain | refills (chronic) | 1 | 3-4 | 3-4x
|
|
230
240
|
* Occasional No-Shows | booking→consult | 95% | 75% | 0.79x
|
|
231
241
|
* Doctor Specialization | years_experience | 5 | 22 | 4.4x
|
|
232
|
-
*
|
|
242
|
+
* Free-Tier Conversion Drop | funnel conversion | 40% | 28% | 0.7x
|
|
233
243
|
*/
|
|
234
244
|
|
|
235
245
|
/** @type {Config} */
|
|
236
246
|
const config = {
|
|
237
|
-
token
|
|
247
|
+
token,
|
|
238
248
|
seed: SEED,
|
|
239
|
-
numDays:
|
|
240
|
-
numEvents: num_users *
|
|
249
|
+
numDays: num_days,
|
|
250
|
+
numEvents: num_users * avg_events_per_user,
|
|
241
251
|
numUsers: num_users,
|
|
242
252
|
hasAnonIds: false,
|
|
243
253
|
hasSessionIds: true,
|
|
@@ -485,7 +495,7 @@ const config = {
|
|
|
485
495
|
// ── SuperProps ──────────────────────────────────────────
|
|
486
496
|
superProps: {
|
|
487
497
|
subscription_tier: ["free", "free", "free", "basic", "basic", "premium"],
|
|
488
|
-
|
|
498
|
+
Platform: ["ios", "android", "web"],
|
|
489
499
|
},
|
|
490
500
|
|
|
491
501
|
// ── UserProps ──────────────────────────────────────────
|
|
@@ -496,6 +506,8 @@ const config = {
|
|
|
496
506
|
preferred_language: ["en", "en", "en", "en", "es", "pt", "de", "fr"],
|
|
497
507
|
has_chronic_condition: [false, false, false, true],
|
|
498
508
|
age_range: ["18-25", "26-35", "26-35", "36-45", "36-45", "46-55", "56-65", "65+"],
|
|
509
|
+
subscription_tier: ["free", "free", "free", "basic", "basic", "premium"],
|
|
510
|
+
Platform: ["ios", "android", "web"],
|
|
499
511
|
},
|
|
500
512
|
|
|
501
513
|
// ── Personas ──────────────────────────────────
|
|
@@ -645,16 +657,9 @@ const config = {
|
|
|
645
657
|
}
|
|
646
658
|
|
|
647
659
|
// ── HOOK 8: PREMIUM BOOKING FUNNEL LIFT (funnel-pre) ─
|
|
648
|
-
//
|
|
660
|
+
// (conversionRate boost removed — filtering applied in everything hook instead)
|
|
649
661
|
if (type === "funnel-pre") {
|
|
650
|
-
|
|
651
|
-
const tier = meta.profile.subscription_tier;
|
|
652
|
-
if (tier === "premium") {
|
|
653
|
-
record.conversionRate = Math.min(record.conversionRate * 1.4, 95);
|
|
654
|
-
} else if (tier === "basic") {
|
|
655
|
-
record.conversionRate = Math.min(record.conversionRate * 1.15, 90);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
662
|
+
// no-op: conversion differentiation handled via event filtering below
|
|
658
663
|
}
|
|
659
664
|
|
|
660
665
|
// ── HOOK 1: AFTER-HOURS SURGE PRICING (event) ────────
|
|
@@ -687,18 +692,42 @@ const config = {
|
|
|
687
692
|
|
|
688
693
|
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
689
694
|
if (type === "everything") {
|
|
690
|
-
|
|
691
|
-
|
|
695
|
+
if (!record.length) return record;
|
|
696
|
+
const profile = meta.profile;
|
|
697
|
+
|
|
698
|
+
// ── SUPER-PROP STAMPING ──────────────────────────
|
|
699
|
+
// Stamp superProps from profile so they are consistent per-user.
|
|
700
|
+
if (profile) {
|
|
701
|
+
const tier = profile.subscription_tier;
|
|
702
|
+
const plat = profile.Platform;
|
|
703
|
+
record.forEach(e => {
|
|
704
|
+
if (tier) e.subscription_tier = tier;
|
|
705
|
+
if (plat) e.Platform = plat;
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── HOOK 8: FREE-TIER CONVERSION DROP ────────────
|
|
710
|
+
// Free-tier users lose ~30% of "consultation completed" events
|
|
711
|
+
// (last step of Booking to Consultation funnel), simulating
|
|
712
|
+
// lower conversion for non-paying patients.
|
|
713
|
+
if (profile && profile.subscription_tier === "free") {
|
|
714
|
+
record = record.filter(e => {
|
|
715
|
+
if (e.event === "consultation completed" && chance.bool({ likelihood: 30 })) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
return true;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
692
721
|
|
|
693
722
|
// ── HOOK 3: EXPERIENCED DOCTOR SATISFACTION ──────
|
|
694
723
|
// Users with >50 consultation events get boosted satisfaction scores.
|
|
695
724
|
let consultCount = 0;
|
|
696
|
-
|
|
725
|
+
record.forEach(e => {
|
|
697
726
|
if (e.event === "consultation completed") consultCount++;
|
|
698
727
|
});
|
|
699
728
|
|
|
700
729
|
if (consultCount > 50) {
|
|
701
|
-
|
|
730
|
+
record.forEach(e => {
|
|
702
731
|
if (e.event === "consultation completed") {
|
|
703
732
|
e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
|
|
704
733
|
}
|
|
@@ -707,18 +736,18 @@ const config = {
|
|
|
707
736
|
|
|
708
737
|
// ── HOOK 4: VIDEO CONSULTATION FOLLOW-UP LIFT ────
|
|
709
738
|
// Patients with video consultations get 2x follow-up events.
|
|
710
|
-
const hasVideoConsult =
|
|
739
|
+
const hasVideoConsult = record.some(e =>
|
|
711
740
|
e.event === "consultation completed" && e.consultation_mode === "video"
|
|
712
741
|
);
|
|
713
742
|
if (hasVideoConsult) {
|
|
714
|
-
const templateFollowUp =
|
|
743
|
+
const templateFollowUp = record.find(e => e.event === "follow up scheduled");
|
|
715
744
|
if (templateFollowUp) {
|
|
716
|
-
const videoConsults =
|
|
745
|
+
const videoConsults = record.filter(e =>
|
|
717
746
|
e.event === "consultation completed" && e.consultation_mode === "video"
|
|
718
747
|
);
|
|
719
748
|
videoConsults.forEach(vc => {
|
|
720
749
|
if (chance.bool({ likelihood: 60 })) {
|
|
721
|
-
|
|
750
|
+
record.push({
|
|
722
751
|
...templateFollowUp,
|
|
723
752
|
time: dayjs(vc.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
|
|
724
753
|
user_id: vc.user_id,
|
|
@@ -732,17 +761,17 @@ const config = {
|
|
|
732
761
|
|
|
733
762
|
// ── HOOK 5: CHRONIC CONDITION REFILL CHAIN ───────
|
|
734
763
|
// Patients with chronic prescriptions get refills every ~30 days.
|
|
735
|
-
const chronicRxs =
|
|
764
|
+
const chronicRxs = record.filter(e =>
|
|
736
765
|
e.event === "prescription issued" && e.condition_type === "chronic"
|
|
737
766
|
);
|
|
738
767
|
if (chronicRxs.length > 0) {
|
|
739
|
-
const templateRefill =
|
|
768
|
+
const templateRefill = record.find(e => e.event === "prescription refill");
|
|
740
769
|
if (templateRefill) {
|
|
741
770
|
chronicRxs.forEach(rx => {
|
|
742
771
|
const rxTime = dayjs(rx.time);
|
|
743
772
|
const refillsToAdd = chance.integer({ min: 2, max: 4 });
|
|
744
773
|
for (let i = 1; i <= refillsToAdd; i++) {
|
|
745
|
-
|
|
774
|
+
record.push({
|
|
746
775
|
...templateRefill,
|
|
747
776
|
time: rxTime.add(30 * i + chance.integer({ min: -3, max: 3 }), "days").toISOString(),
|
|
748
777
|
user_id: rx.user_id,
|
|
@@ -757,10 +786,10 @@ const config = {
|
|
|
757
786
|
|
|
758
787
|
// ── HOOK 6: OCCASIONAL PATIENT NO-SHOWS ──────────
|
|
759
788
|
// Low-activity patients (< 15 events) lose 25% of appointments.
|
|
760
|
-
if (
|
|
761
|
-
for (let i =
|
|
762
|
-
if (
|
|
763
|
-
|
|
789
|
+
if (record.length < 15) {
|
|
790
|
+
for (let i = record.length - 1; i >= 0; i--) {
|
|
791
|
+
if (record[i].event === "appointment booked" && chance.bool({ likelihood: 25 })) {
|
|
792
|
+
record.splice(i, 1);
|
|
764
793
|
}
|
|
765
794
|
}
|
|
766
795
|
}
|