@ak--47/dungeon-master 1.2.2 → 1.3.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/CHANGELOG.md +42 -0
- package/README.md +51 -13
- package/dungeons/technical/ad-spend.js +2 -2
- package/dungeons/technical/anonymous-users.js +2 -2
- package/dungeons/technical/array-of-object-lookup.js +2 -2
- package/dungeons/technical/experiments.js +2 -2
- package/dungeons/technical/foobar.js +2 -2
- package/dungeons/technical/group-analytics.js +2 -2
- package/dungeons/technical/mirror-strategies.js +2 -2
- package/dungeons/technical/nested-objects.js +2 -2
- package/dungeons/technical/retention-cadence.js +2 -3
- package/dungeons/technical/sanity.js +2 -2
- package/dungeons/technical/scale-test.js +2 -2
- package/dungeons/technical/scd.js +2 -2
- package/dungeons/technical/simple.js +2 -2
- package/dungeons/technical/simplest.js +2 -2
- package/dungeons/technical/text-generation.js +2 -2
- package/dungeons/vertical/ai-platform-schema.json +617 -0
- package/dungeons/vertical/ai-platform.js +799 -0
- package/dungeons/vertical/community.js +40 -26
- package/dungeons/vertical/crypto-schema.json +546 -0
- package/dungeons/vertical/crypto.js +721 -0
- package/dungeons/vertical/dating-schema.json +401 -0
- package/dungeons/vertical/dating.js +798 -0
- package/dungeons/vertical/devtools.js +13 -9
- package/dungeons/vertical/ecommerce.js +2 -3
- package/dungeons/vertical/education.js +4 -5
- package/dungeons/vertical/fintech.js +32 -29
- package/dungeons/vertical/fitness.js +2 -3
- package/dungeons/vertical/food-delivery.js +37 -43
- package/dungeons/vertical/gaming-schema.json +2495 -230
- package/dungeons/vertical/gaming.js +771 -388
- package/dungeons/vertical/healthcare.js +2 -3
- package/dungeons/vertical/insurance-application.js +2 -3
- package/dungeons/vertical/logistics.js +20 -14
- package/dungeons/vertical/marketplace.js +22 -14
- package/dungeons/vertical/media.js +39 -30
- package/dungeons/vertical/real-estate-schema.json +527 -0
- package/dungeons/vertical/real-estate.js +774 -0
- package/dungeons/vertical/sass.js +2 -3
- package/dungeons/vertical/social.js +15 -13
- package/dungeons/vertical/travel.js +59 -26
- package/lib/core/config-validator.js +71 -15
- package/lib/core/storage.js +14 -4
- package/lib/orchestrators/user-loop.js +39 -5
- package/lib/templates/macro-presets.js +111 -0
- package/lib/templates/soup-presets.js +19 -36
- package/package.json +8 -2
- package/types.d.ts +219 -42
- package/dungeons/user/.gitkeep +0 -0
- package/dungeons/vertical/rpg-schema.json +0 -2491
- package/dungeons/vertical/rpg.js +0 -976
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// ── TWEAK THESE ──
|
|
2
|
+
const SEED = "meetcute";
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 8_000;
|
|
5
|
+
const avg_events_per_user_per_day = 0.75;
|
|
6
|
+
let token = "your-mixpanel-token";
|
|
7
|
+
|
|
8
|
+
// ── env overrides ──
|
|
9
|
+
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
+
|
|
11
|
+
import dayjs from "dayjs";
|
|
12
|
+
import utc from "dayjs/plugin/utc.js";
|
|
13
|
+
import "dotenv/config";
|
|
14
|
+
import * as u from "../../lib/utils/utils.js";
|
|
15
|
+
import * as v from "ak-tools";
|
|
16
|
+
|
|
17
|
+
dayjs.extend(utc);
|
|
18
|
+
const chance = u.initChance(SEED);
|
|
19
|
+
const NOW = dayjs();
|
|
20
|
+
const DATASET_START = NOW.subtract(num_days, "days");
|
|
21
|
+
|
|
22
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* =====================================================================================
|
|
26
|
+
* DATASET OVERVIEW
|
|
27
|
+
* =====================================================================================
|
|
28
|
+
*
|
|
29
|
+
* MeetCute — a swipe-based dating app (Hinge/Tinder-style) with profile
|
|
30
|
+
* prompts, photo verification, matchmaking, messaging, and premium tiers.
|
|
31
|
+
*
|
|
32
|
+
* CORE LOOP:
|
|
33
|
+
* Users create a profile with photos and prompts, swipe on potential
|
|
34
|
+
* matches, receive matches, message matches, exchange phone numbers,
|
|
35
|
+
* and schedule dates. Premium subscribers get boosts, super-likes,
|
|
36
|
+
* and see-who-liked-you.
|
|
37
|
+
*
|
|
38
|
+
* - 8,000 users over 120 days
|
|
39
|
+
* - ~720,000 base events across 17 event types
|
|
40
|
+
* - 4 funnels (onboarding, match flow, date funnel, monetization)
|
|
41
|
+
* - 3 subscription tiers: Free, Premium, Elite
|
|
42
|
+
*
|
|
43
|
+
* Key entities:
|
|
44
|
+
* - swipe_source: feed / discover / boost / nearby
|
|
45
|
+
* - subscription: Free / Premium / Elite
|
|
46
|
+
* - venue_type: coffee / dinner / drinks / activity / virtual
|
|
47
|
+
* - prompt_type: icebreaker / opinion / hypothetical / personal / creative
|
|
48
|
+
*
|
|
49
|
+
* =====================================================================================
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* =====================================================================================
|
|
54
|
+
* ANALYTICS HOOKS (8 hooks)
|
|
55
|
+
* =====================================================================================
|
|
56
|
+
*
|
|
57
|
+
* -------------------------------------------------------------------------------------
|
|
58
|
+
* 1. PHOTO UPLOAD CONVERSION (everything hook)
|
|
59
|
+
* -------------------------------------------------------------------------------------
|
|
60
|
+
* PATTERN: Users with 4+ "photo uploaded" events get 5x more "match received"
|
|
61
|
+
* events injected (cloned from existing matches). complete_profile=true on
|
|
62
|
+
* those match events. Complete profiles are magnets.
|
|
63
|
+
*
|
|
64
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
65
|
+
* Report 1: Match Volume by Profile Completeness
|
|
66
|
+
* - Insights > "match received" > Total per user > Breakdown: "complete_profile"
|
|
67
|
+
* - Expected: complete_profile=true users have ~5x more matches
|
|
68
|
+
*
|
|
69
|
+
* Report 2: Photo Count Correlation
|
|
70
|
+
* - Insights > "photo uploaded" > Uniques > Compare to "match received" > Uniques
|
|
71
|
+
* - Expected: strong positive correlation between photo uploads and matches
|
|
72
|
+
*
|
|
73
|
+
* -------------------------------------------------------------------------------------
|
|
74
|
+
* 2. WEEKEND SWIPE SURGE (event hook)
|
|
75
|
+
* -------------------------------------------------------------------------------------
|
|
76
|
+
* PATTERN: On Sunday evenings (day=0, hour 18-23), "swipe right" events are
|
|
77
|
+
* duplicated 2.5x with weekend_surge=true. The Sunday Scaries drive swipes.
|
|
78
|
+
*
|
|
79
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
80
|
+
* Report 1: Swipe Volume by Day of Week
|
|
81
|
+
* - Insights (bar) > "swipe right" > Total > Breakdown: Day of Week
|
|
82
|
+
* - Expected: Sunday bar significantly taller than other days
|
|
83
|
+
*
|
|
84
|
+
* Report 2: Weekend Surge Flag
|
|
85
|
+
* - Insights (line) > "swipe right" > Total > Breakdown: "weekend_surge"
|
|
86
|
+
* - Expected: weekend_surge=true events clustered on Sunday evenings
|
|
87
|
+
*
|
|
88
|
+
* -------------------------------------------------------------------------------------
|
|
89
|
+
* 3. SUPER-LIKE EFFECT (everything hook)
|
|
90
|
+
* -------------------------------------------------------------------------------------
|
|
91
|
+
* PATTERN: "swipe right" events with is_super_like=true generate 3x more
|
|
92
|
+
* "match received" events nearby in time. super_like_match=true on those
|
|
93
|
+
* matches. Super-likes dramatically improve match rates.
|
|
94
|
+
*
|
|
95
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
96
|
+
* Report 1: Match Rate by Super-Like
|
|
97
|
+
* - Insights > "match received" > Total per user > Breakdown: "super_like_match"
|
|
98
|
+
* - Expected: super_like_match=true concentrated among super-likers
|
|
99
|
+
*
|
|
100
|
+
* Report 2: Super-Like Conversion
|
|
101
|
+
* - Funnels > "swipe right" (filter: is_super_like=true) > "match received"
|
|
102
|
+
* - Compare to: "swipe right" (filter: is_super_like=false) > "match received"
|
|
103
|
+
* - Expected: super-like funnel ~3x higher conversion
|
|
104
|
+
*
|
|
105
|
+
* -------------------------------------------------------------------------------------
|
|
106
|
+
* 4. PREMIUM MATCH BOOST (everything hook)
|
|
107
|
+
* -------------------------------------------------------------------------------------
|
|
108
|
+
* PATTERN: Premium subscribers get 2x "match received" events. Elite
|
|
109
|
+
* subscribers get 4x + injected "profile viewed" events (see-who-liked-you).
|
|
110
|
+
* premium_boost=true on injected events. Subscription pays off.
|
|
111
|
+
*
|
|
112
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
113
|
+
* Report 1: Matches by Subscription Tier
|
|
114
|
+
* - Insights > "match received" > Total per user > Breakdown: user prop "subscription"
|
|
115
|
+
* - Expected: Free ~baseline, Premium ~2x, Elite ~4x
|
|
116
|
+
*
|
|
117
|
+
* Report 2: Profile Views (Elite Exclusive)
|
|
118
|
+
* - Insights > "profile viewed" > Total > Breakdown: "premium_boost"
|
|
119
|
+
* - Expected: premium_boost=true events only from Elite subscribers
|
|
120
|
+
*
|
|
121
|
+
* -------------------------------------------------------------------------------------
|
|
122
|
+
* 5. GHOSTING CHURN (everything hook)
|
|
123
|
+
* -------------------------------------------------------------------------------------
|
|
124
|
+
* PATTERN: Users who receive a match but send zero messages within 48 hours
|
|
125
|
+
* lose 70% of their events after the match date. ghosted=true stamped on
|
|
126
|
+
* remaining "app opened" events. Ghosters churn.
|
|
127
|
+
*
|
|
128
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
129
|
+
* Report 1: Retention by Messaging Behavior
|
|
130
|
+
* - Retention > Starting: "match received" > Return: any event
|
|
131
|
+
* - Segment: users who did "message sent" vs users who didn't
|
|
132
|
+
* - Expected: non-messengers have ~70% lower retention
|
|
133
|
+
*
|
|
134
|
+
* Report 2: Ghost Flag
|
|
135
|
+
* - Insights (line) > "app opened" > Total > Breakdown: "ghosted"
|
|
136
|
+
* - Expected: ghosted=true users trail off sharply after match date
|
|
137
|
+
*
|
|
138
|
+
* -------------------------------------------------------------------------------------
|
|
139
|
+
* 6. BIO + PROMPT POWER USERS (everything hook)
|
|
140
|
+
* -------------------------------------------------------------------------------------
|
|
141
|
+
* PATTERN: Users with both "bio updated" AND 3+ "prompt answered" events
|
|
142
|
+
* get 4x "date scheduled" events. complete_profile_power=true on injected
|
|
143
|
+
* dates. Effort on profile = real dates.
|
|
144
|
+
*
|
|
145
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
146
|
+
* Report 1: Dates by Profile Effort
|
|
147
|
+
* - Insights > "date scheduled" > Total per user > Breakdown: "complete_profile_power"
|
|
148
|
+
* - Expected: complete_profile_power=true users schedule ~4x more dates
|
|
149
|
+
*
|
|
150
|
+
* Report 2: Profile Effort Funnel
|
|
151
|
+
* - Funnels > "bio updated" > "prompt answered" > "date scheduled"
|
|
152
|
+
* - Expected: users completing all 3 have dramatically higher date rates
|
|
153
|
+
*
|
|
154
|
+
* -------------------------------------------------------------------------------------
|
|
155
|
+
* 7. VALENTINE'S DAY SPIKE (event + everything hook)
|
|
156
|
+
* -------------------------------------------------------------------------------------
|
|
157
|
+
* PATTERN: Day 60 = Feb 14. Days 58-63: "profile created" events 3x via
|
|
158
|
+
* cloned events (valentines=true). In everything hook, "premium upgrade"
|
|
159
|
+
* events 5x for users active during V-Day window. Love is expensive.
|
|
160
|
+
*
|
|
161
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
162
|
+
* Report 1: Signup Spike
|
|
163
|
+
* - Insights (line) > "profile created" > Total > Daily
|
|
164
|
+
* - Expected: sharp 3x spike around day 58-63
|
|
165
|
+
*
|
|
166
|
+
* Report 2: Premium Upgrades Around V-Day
|
|
167
|
+
* - Insights (line) > "premium upgrade" > Total > Breakdown: "valentines"
|
|
168
|
+
* - Expected: 5x premium upgrades during V-Day window
|
|
169
|
+
*
|
|
170
|
+
* -------------------------------------------------------------------------------------
|
|
171
|
+
* 8. OFF-APP RETENTION (everything hook)
|
|
172
|
+
* -------------------------------------------------------------------------------------
|
|
173
|
+
* PATTERN: Users with "phone number exchanged" OR "date scheduled" in first
|
|
174
|
+
* 14 days maintain 60% D30 retention (extra events injected). Users without
|
|
175
|
+
* these milestones lose events after day 30 — the app becomes irrelevant
|
|
176
|
+
* once they find someone IRL.
|
|
177
|
+
*
|
|
178
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
179
|
+
* Report 1: Retention by Milestone
|
|
180
|
+
* - Retention > Starting: "profile created" > Return: any event
|
|
181
|
+
* - Segment: users who did "date scheduled" in first 14 days vs not
|
|
182
|
+
* - Expected: milestone users ~60% D30 retention vs ~20% for others
|
|
183
|
+
*
|
|
184
|
+
* Report 2: Long-Term Engagement
|
|
185
|
+
* - Insights (line) > "app opened" > Total per user > Weekly > Breakdown: user prop "subscription"
|
|
186
|
+
* - Expected: after week 4, non-milestone users nearly disappear
|
|
187
|
+
*
|
|
188
|
+
* =====================================================================================
|
|
189
|
+
* EXPECTED METRICS SUMMARY
|
|
190
|
+
* =====================================================================================
|
|
191
|
+
*
|
|
192
|
+
* Hook | Metric | Baseline | Effect | Ratio
|
|
193
|
+
* ───────────────────────────-|──────────────────────-|──────────|──────────|──────
|
|
194
|
+
* Photo Upload Conversion | matches/user | ~3 | ~15 | 5x
|
|
195
|
+
* Weekend Swipe Surge | Sunday swipes | 1x | 2.5x | 2.5x
|
|
196
|
+
* Super-Like Effect | matches from super | 1x | 3x | 3x
|
|
197
|
+
* Premium Match Boost | matches (Elite) | 1x | 4x | 4x
|
|
198
|
+
* Ghosting Churn | post-match retention | 100% | 30% | 0.3x
|
|
199
|
+
* Bio + Prompt Power Users | dates/user | 1x | 4x | 4x
|
|
200
|
+
* Valentine's Day Spike | signups (days 58-63) | 1x | 3x | 3x
|
|
201
|
+
* Off-App Retention | D30 retention | ~20% | ~60% | 3x
|
|
202
|
+
*
|
|
203
|
+
* =====================================================================================
|
|
204
|
+
* ADVANCED ANALYSIS IDEAS
|
|
205
|
+
* =====================================================================================
|
|
206
|
+
*
|
|
207
|
+
* - Photo Conversion + Premium Boost: Do complete-profile Elite users compound
|
|
208
|
+
* to 20x matches (5x photos * 4x Elite)?
|
|
209
|
+
* - Weekend Surge + Super-Like: Sunday evening super-likes should be the
|
|
210
|
+
* highest-converting swipe segment.
|
|
211
|
+
* - Ghosting + Off-App Retention: Users who ghost AND never schedule a date
|
|
212
|
+
* churn fastest — double negative retention effect.
|
|
213
|
+
* - Valentine's Spike + Monetization: V-Day signups who upgrade to Premium
|
|
214
|
+
* within 7 days have the highest LTV window.
|
|
215
|
+
* - Bio/Prompt Power + Date Scheduling: Complete profile users schedule dates
|
|
216
|
+
* at 4x the rate, but do they also have higher message response rates?
|
|
217
|
+
* - Cohort by age_range, gender, subscription tier, and signup week.
|
|
218
|
+
* - Funnel analysis: onboarding by gender, match flow by subscription,
|
|
219
|
+
* date funnel by profile completeness.
|
|
220
|
+
* =====================================================================================
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
/** @type {Config} */
|
|
224
|
+
const config = {
|
|
225
|
+
token,
|
|
226
|
+
seed: SEED,
|
|
227
|
+
numDays: num_days,
|
|
228
|
+
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
229
|
+
numUsers: num_users,
|
|
230
|
+
hasAnonIds: false,
|
|
231
|
+
hasSessionIds: true,
|
|
232
|
+
format: "json",
|
|
233
|
+
gzip: true,
|
|
234
|
+
alsoInferFunnels: false,
|
|
235
|
+
hasLocation: true,
|
|
236
|
+
hasAndroidDevices: true,
|
|
237
|
+
hasIOSDevices: true,
|
|
238
|
+
hasDesktopDevices: false,
|
|
239
|
+
hasBrowser: false,
|
|
240
|
+
hasCampaigns: false,
|
|
241
|
+
isAnonymous: false,
|
|
242
|
+
hasAdSpend: false,
|
|
243
|
+
hasAvatar: true,
|
|
244
|
+
concurrency: 1,
|
|
245
|
+
writeToDisk: false,
|
|
246
|
+
soup: "growth",
|
|
247
|
+
|
|
248
|
+
// ── Events (17) ──────────────────────────────────────────
|
|
249
|
+
events: [
|
|
250
|
+
{
|
|
251
|
+
event: "profile created",
|
|
252
|
+
weight: 1,
|
|
253
|
+
isFirstEvent: true,
|
|
254
|
+
properties: {
|
|
255
|
+
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
256
|
+
gender: ["Male", "Male", "Female", "Female", "Non-binary"],
|
|
257
|
+
looking_for: ["Men", "Women", "Everyone"],
|
|
258
|
+
valentines: [false],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
event: "photo uploaded",
|
|
263
|
+
weight: 3,
|
|
264
|
+
properties: {
|
|
265
|
+
photo_number: u.weighNumRange(1, 6, 0.5, 2),
|
|
266
|
+
has_face: [true, true, true, true, false],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
event: "bio updated",
|
|
271
|
+
weight: 2,
|
|
272
|
+
properties: {
|
|
273
|
+
bio_length: u.weighNumRange(10, 500, 0.4, 120),
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
event: "prompt answered",
|
|
278
|
+
weight: 3,
|
|
279
|
+
properties: {
|
|
280
|
+
prompt_type: ["icebreaker", "opinion", "hypothetical", "personal", "creative"],
|
|
281
|
+
answer_length: u.weighNumRange(10, 300, 0.4, 80),
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
event: "swipe right",
|
|
286
|
+
weight: 10,
|
|
287
|
+
properties: {
|
|
288
|
+
is_super_like: [false, false, false, false, false, false, false, false, false, true],
|
|
289
|
+
weekend_surge: [false],
|
|
290
|
+
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
event: "swipe left",
|
|
295
|
+
weight: 8,
|
|
296
|
+
properties: {
|
|
297
|
+
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
event: "match received",
|
|
302
|
+
weight: 4,
|
|
303
|
+
properties: {
|
|
304
|
+
match_score: u.weighNumRange(50, 100, 0.5, 75),
|
|
305
|
+
complete_profile: [false],
|
|
306
|
+
super_like_match: [false],
|
|
307
|
+
premium_boost: [false],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
event: "message sent",
|
|
312
|
+
weight: 6,
|
|
313
|
+
properties: {
|
|
314
|
+
message_length: u.weighNumRange(1, 500, 0.3, 40),
|
|
315
|
+
has_emoji: [false, false, true, true, true],
|
|
316
|
+
response_time_mins: u.weighNumRange(1, 1440, 0.3, 30),
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
event: "message received",
|
|
321
|
+
weight: 5,
|
|
322
|
+
properties: {
|
|
323
|
+
message_length: u.weighNumRange(1, 500, 0.3, 50),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
event: "phone number exchanged",
|
|
328
|
+
weight: 1,
|
|
329
|
+
properties: {
|
|
330
|
+
exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
event: "date scheduled",
|
|
335
|
+
weight: 1,
|
|
336
|
+
properties: {
|
|
337
|
+
venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
|
|
338
|
+
complete_profile_power: [false],
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
event: "profile viewed",
|
|
343
|
+
weight: 5,
|
|
344
|
+
properties: {
|
|
345
|
+
viewer_source: ["feed", "discover", "liked_you", "nearby"],
|
|
346
|
+
premium_boost: [false],
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
event: "premium upgrade",
|
|
351
|
+
weight: 1,
|
|
352
|
+
properties: {
|
|
353
|
+
plan: ["Premium", "Premium", "Premium", "Elite"],
|
|
354
|
+
price_usd: [14.99, 14.99, 14.99, 29.99],
|
|
355
|
+
valentines: [false],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
event: "premium cancelled",
|
|
360
|
+
weight: 1,
|
|
361
|
+
isStrictEvent: true,
|
|
362
|
+
properties: {
|
|
363
|
+
cancel_reason: ["found_someone", "too_expensive", "not_enough_matches", "bad_experience", "taking_a_break"],
|
|
364
|
+
subscription_duration_days: u.weighNumRange(7, 365, 0.3, 30),
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
event: "boost activated",
|
|
369
|
+
weight: 2,
|
|
370
|
+
properties: {
|
|
371
|
+
boost_type: ["standard", "super"],
|
|
372
|
+
boost_duration_mins: [15, 30, 30, 60],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
event: "report user",
|
|
377
|
+
weight: 1,
|
|
378
|
+
isStrictEvent: true,
|
|
379
|
+
properties: {
|
|
380
|
+
report_reason: ["fake_profile", "inappropriate_photos", "harassment", "spam", "underage"],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
event: "app opened",
|
|
385
|
+
weight: 8,
|
|
386
|
+
properties: {
|
|
387
|
+
session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
|
|
388
|
+
ghosted: [false],
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
|
|
393
|
+
// ── Funnels (4) ──────────────────────────────────────────
|
|
394
|
+
funnels: [
|
|
395
|
+
{
|
|
396
|
+
name: "Onboarding",
|
|
397
|
+
sequence: ["profile created", "photo uploaded", "swipe right"],
|
|
398
|
+
conversionRate: 75,
|
|
399
|
+
order: "sequential",
|
|
400
|
+
isFirstFunnel: true,
|
|
401
|
+
timeToConvert: 1,
|
|
402
|
+
weight: 3,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "Match Flow",
|
|
406
|
+
sequence: ["swipe right", "match received", "message sent"],
|
|
407
|
+
conversionRate: 50,
|
|
408
|
+
order: "sequential",
|
|
409
|
+
timeToConvert: 24,
|
|
410
|
+
weight: 6,
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: "Date Funnel",
|
|
414
|
+
sequence: ["message sent", "phone number exchanged", "date scheduled"],
|
|
415
|
+
conversionRate: 25,
|
|
416
|
+
order: "sequential",
|
|
417
|
+
timeToConvert: 72,
|
|
418
|
+
weight: 3,
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "Monetization",
|
|
422
|
+
sequence: ["app opened", "boost activated", "premium upgrade"],
|
|
423
|
+
conversionRate: 20,
|
|
424
|
+
order: "sequential",
|
|
425
|
+
timeToConvert: 48,
|
|
426
|
+
weight: 2,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
|
|
430
|
+
// ── SuperProps ──────────────────────────────────────────
|
|
431
|
+
superProps: {
|
|
432
|
+
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
433
|
+
platform: ["ios", "ios", "android"],
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
// ── UserProps ──────────────────────────────────────────
|
|
437
|
+
userProps: {
|
|
438
|
+
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
439
|
+
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
440
|
+
gender: ["Male", "Male", "Female", "Female", "Non-binary"],
|
|
441
|
+
looking_for: ["Men", "Women", "Everyone"],
|
|
442
|
+
photo_count: u.weighNumRange(0, 8, 0.4, 3),
|
|
443
|
+
total_matches: u.weighNumRange(0, 200, 0.3, 15),
|
|
444
|
+
total_messages_sent: u.weighNumRange(0, 500, 0.3, 30),
|
|
445
|
+
profile_completeness: ["incomplete", "incomplete", "basic", "basic", "complete"],
|
|
446
|
+
platform: ["ios", "ios", "android"],
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// ── SCD Props ──────────────────────────────────────────
|
|
450
|
+
scdProps: {
|
|
451
|
+
subscription_tier: {
|
|
452
|
+
values: ["Free", "Premium", "Elite"],
|
|
453
|
+
frequency: "month",
|
|
454
|
+
timing: "fuzzy",
|
|
455
|
+
max: 6,
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
groupKeys: [],
|
|
460
|
+
groupProps: {},
|
|
461
|
+
mirrorProps: {},
|
|
462
|
+
lookupTables: [],
|
|
463
|
+
|
|
464
|
+
// ── Hook Function ──────────────────────────────────────
|
|
465
|
+
hook: function (record, type, meta) {
|
|
466
|
+
const VALENTINES_DAY = DATASET_START.add(60, "days");
|
|
467
|
+
const VDAY_WINDOW_START = DATASET_START.add(58, "days");
|
|
468
|
+
const VDAY_WINDOW_END = DATASET_START.add(63, "days");
|
|
469
|
+
|
|
470
|
+
// ─── EVENT-LEVEL HOOKS ───────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
if (type === "event") {
|
|
473
|
+
const EVENT_TIME = dayjs(record.time);
|
|
474
|
+
|
|
475
|
+
// ── HOOK 2: WEEKEND SWIPE SURGE ─────────────────
|
|
476
|
+
// Tagging moved to everything hook (sessionization reassigns times after event hook)
|
|
477
|
+
|
|
478
|
+
// ── HOOK 7: VALENTINE'S DAY SPIKE (event part) ──
|
|
479
|
+
// Days 58-63: tag profile created events for 3x duplication
|
|
480
|
+
if (record.event === "profile created") {
|
|
481
|
+
if (EVENT_TIME.isAfter(VDAY_WINDOW_START) && EVENT_TIME.isBefore(VDAY_WINDOW_END)) {
|
|
482
|
+
record.valentines = true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
if (type === "everything") {
|
|
490
|
+
const events = record;
|
|
491
|
+
if (!events || events.length === 0) return record;
|
|
492
|
+
|
|
493
|
+
const profile = meta.profile || {};
|
|
494
|
+
|
|
495
|
+
// Stamp superProps from profile for consistency
|
|
496
|
+
events.forEach(e => {
|
|
497
|
+
if (profile.subscription) e.subscription = profile.subscription;
|
|
498
|
+
if (profile.platform) e.platform = profile.platform;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ── First pass: scan user patterns ──
|
|
502
|
+
let photoUploadCount = 0;
|
|
503
|
+
let promptAnsweredCount = 0;
|
|
504
|
+
let hasBioUpdated = false;
|
|
505
|
+
let matchEvents = [];
|
|
506
|
+
let messageSentEvents = [];
|
|
507
|
+
let hasSuperLike = false;
|
|
508
|
+
let superLikeEvents = [];
|
|
509
|
+
let hasPhoneExchangedEarly = false;
|
|
510
|
+
let hasDateScheduledEarly = false;
|
|
511
|
+
let firstEventTime = null;
|
|
512
|
+
|
|
513
|
+
events.forEach(event => {
|
|
514
|
+
if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
|
|
515
|
+
firstEventTime = event.time;
|
|
516
|
+
}
|
|
517
|
+
if (event.event === "photo uploaded") photoUploadCount++;
|
|
518
|
+
if (event.event === "prompt answered") promptAnsweredCount++;
|
|
519
|
+
if (event.event === "bio updated") hasBioUpdated = true;
|
|
520
|
+
if (event.event === "match received") matchEvents.push(event);
|
|
521
|
+
if (event.event === "message sent") messageSentEvents.push(event);
|
|
522
|
+
if (event.event === "swipe right" && event.is_super_like === true) {
|
|
523
|
+
hasSuperLike = true;
|
|
524
|
+
superLikeEvents.push(event);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Check early milestones (first 14 days)
|
|
529
|
+
if (firstEventTime) {
|
|
530
|
+
const earlyWindow = dayjs(firstEventTime).add(14, "days");
|
|
531
|
+
events.forEach(event => {
|
|
532
|
+
const t = dayjs(event.time);
|
|
533
|
+
if (t.isBefore(earlyWindow)) {
|
|
534
|
+
if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
|
|
535
|
+
if (event.event === "date scheduled") hasDateScheduledEarly = true;
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── HOOK 1: PHOTO UPLOAD CONVERSION ─────────────
|
|
541
|
+
// Users with 4+ photo uploads get 5x match events
|
|
542
|
+
if (photoUploadCount >= 4 && matchEvents.length > 0) {
|
|
543
|
+
const matchTemplate = matchEvents[0];
|
|
544
|
+
const extraMatches = matchEvents.length * 4; // 4 additional = 5x total
|
|
545
|
+
for (let i = 0; i < extraMatches; i++) {
|
|
546
|
+
const sourceMatch = matchEvents[i % matchEvents.length];
|
|
547
|
+
events.push({
|
|
548
|
+
...matchTemplate,
|
|
549
|
+
event: "match received",
|
|
550
|
+
time: dayjs(sourceMatch.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
|
|
551
|
+
user_id: sourceMatch.user_id,
|
|
552
|
+
match_score: chance.integer({ min: 60, max: 98 }),
|
|
553
|
+
complete_profile: true,
|
|
554
|
+
super_like_match: false,
|
|
555
|
+
premium_boost: false,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── HOOK 2: WEEKEND SWIPE SURGE (everything part) ─
|
|
561
|
+
// Tag Sunday evening swipes AFTER sessionization has finalized times
|
|
562
|
+
events.forEach(event => {
|
|
563
|
+
if (event.event === "swipe right") {
|
|
564
|
+
const dow = new Date(event.time).getUTCDay();
|
|
565
|
+
const hr = new Date(event.time).getUTCHours();
|
|
566
|
+
if (dow === 0 && hr >= 18 && hr <= 23) {
|
|
567
|
+
event.weekend_surge = true;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// Duplicate weekend_surge=true swipes to create 2.5x volume
|
|
572
|
+
for (let idx = events.length - 1; idx >= 0; idx--) {
|
|
573
|
+
const event = events[idx];
|
|
574
|
+
if (event.event === "swipe right" && event.weekend_surge === true) {
|
|
575
|
+
// Add 1.5 extra copies on average (1 always + 50% chance of second)
|
|
576
|
+
const etime = dayjs(event.time);
|
|
577
|
+
events.push({
|
|
578
|
+
...event,
|
|
579
|
+
time: etime.add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
|
|
580
|
+
user_id: event.user_id,
|
|
581
|
+
});
|
|
582
|
+
if (chance.bool({ likelihood: 50 })) {
|
|
583
|
+
events.push({
|
|
584
|
+
...event,
|
|
585
|
+
time: etime.add(chance.integer({ min: 5, max: 60 }), "minutes").toISOString(),
|
|
586
|
+
user_id: event.user_id,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── HOOK 3: SUPER-LIKE EFFECT ───────────────────
|
|
593
|
+
// Super-likes generate 3x more matches nearby in time
|
|
594
|
+
if (hasSuperLike && superLikeEvents.length > 0) {
|
|
595
|
+
const matchTemplate = matchEvents.length > 0
|
|
596
|
+
? matchEvents[0]
|
|
597
|
+
: events.find(e => e.event === "swipe right") || events[0];
|
|
598
|
+
|
|
599
|
+
superLikeEvents.forEach(sle => {
|
|
600
|
+
for (let i = 0; i < 3; i++) {
|
|
601
|
+
events.push({
|
|
602
|
+
...matchTemplate,
|
|
603
|
+
event: "match received",
|
|
604
|
+
time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
|
|
605
|
+
user_id: sle.user_id,
|
|
606
|
+
match_score: chance.integer({ min: 70, max: 99 }),
|
|
607
|
+
complete_profile: false,
|
|
608
|
+
super_like_match: true,
|
|
609
|
+
premium_boost: false,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── HOOK 4: PREMIUM MATCH BOOST ─────────────────
|
|
616
|
+
// Premium: 2x matches. Elite: 4x matches + profile viewed (see-who-liked-you).
|
|
617
|
+
const sub = profile.subscription;
|
|
618
|
+
if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
|
|
619
|
+
const multiplier = sub === "Elite" ? 3 : 1; // +3 = 4x total for Elite, +1 = 2x for Premium
|
|
620
|
+
const matchTemplate = matchEvents[0];
|
|
621
|
+
|
|
622
|
+
for (let i = 0; i < matchEvents.length * multiplier; i++) {
|
|
623
|
+
const sourceMatch = matchEvents[i % matchEvents.length];
|
|
624
|
+
events.push({
|
|
625
|
+
...matchTemplate,
|
|
626
|
+
event: "match received",
|
|
627
|
+
time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
|
|
628
|
+
user_id: sourceMatch.user_id,
|
|
629
|
+
match_score: chance.integer({ min: 65, max: 99 }),
|
|
630
|
+
complete_profile: false,
|
|
631
|
+
super_like_match: false,
|
|
632
|
+
premium_boost: true,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Elite exclusive: inject "profile viewed" events (see-who-liked-you)
|
|
637
|
+
if (sub === "Elite") {
|
|
638
|
+
const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
|
|
639
|
+
matchEvents.forEach(m => {
|
|
640
|
+
events.push({
|
|
641
|
+
...viewTemplate,
|
|
642
|
+
event: "profile viewed",
|
|
643
|
+
time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
644
|
+
user_id: m.user_id,
|
|
645
|
+
viewer_source: "liked_you",
|
|
646
|
+
premium_boost: true,
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── HOOK 6: BIO + PROMPT POWER USERS ────────────
|
|
653
|
+
// Users with bio updated AND 3+ prompts get 4x date scheduled
|
|
654
|
+
if (hasBioUpdated && promptAnsweredCount >= 3) {
|
|
655
|
+
const dateEvents = events.filter(e => e.event === "date scheduled");
|
|
656
|
+
if (dateEvents.length > 0) {
|
|
657
|
+
const dateTemplate = dateEvents[0];
|
|
658
|
+
const extraDates = dateEvents.length * 3; // +3 = 4x total
|
|
659
|
+
const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
|
|
660
|
+
for (let i = 0; i < extraDates; i++) {
|
|
661
|
+
const sourceDate = dateEvents[i % dateEvents.length];
|
|
662
|
+
events.push({
|
|
663
|
+
...dateTemplate,
|
|
664
|
+
event: "date scheduled",
|
|
665
|
+
time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
666
|
+
user_id: sourceDate.user_id,
|
|
667
|
+
venue_type: chance.pickone(venueTypes),
|
|
668
|
+
complete_profile_power: true,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── HOOK 7: VALENTINE'S DAY SPIKE (everything part) ─
|
|
675
|
+
// 3x profile created events during V-Day window (via cloning tagged events)
|
|
676
|
+
const vdaySignups = events.filter(e => e.event === "profile created" && e.valentines === true);
|
|
677
|
+
vdaySignups.forEach(signup => {
|
|
678
|
+
for (let i = 0; i < 2; i++) { // +2 clones = 3x total
|
|
679
|
+
events.push({
|
|
680
|
+
...signup,
|
|
681
|
+
time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
682
|
+
user_id: signup.user_id,
|
|
683
|
+
valentines: true,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// 5x premium upgrade events for users active during V-Day window
|
|
689
|
+
const vdayUpgrades = events.filter(e =>
|
|
690
|
+
e.event === "premium upgrade" &&
|
|
691
|
+
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
692
|
+
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
693
|
+
);
|
|
694
|
+
if (vdayUpgrades.length > 0) {
|
|
695
|
+
const upgradeTemplate = vdayUpgrades[0];
|
|
696
|
+
vdayUpgrades.forEach(upgrade => {
|
|
697
|
+
for (let i = 0; i < 4; i++) { // +4 clones = 5x total
|
|
698
|
+
events.push({
|
|
699
|
+
...upgradeTemplate,
|
|
700
|
+
event: "premium upgrade",
|
|
701
|
+
time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
|
|
702
|
+
user_id: upgrade.user_id,
|
|
703
|
+
plan: upgrade.plan,
|
|
704
|
+
price_usd: upgrade.price_usd,
|
|
705
|
+
valentines: true,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── HOOK 5: GHOSTING CHURN ──────────────────────
|
|
712
|
+
// Users with match but no message within 48 hours → lose 70% of later events
|
|
713
|
+
let isGhoster = false;
|
|
714
|
+
if (matchEvents.length > 0) {
|
|
715
|
+
// Check if any match has a message within 48 hours
|
|
716
|
+
let hasTimely = false;
|
|
717
|
+
for (const m of matchEvents) {
|
|
718
|
+
const matchTime = dayjs(m.time);
|
|
719
|
+
const deadline = matchTime.add(48, "hours");
|
|
720
|
+
for (const msg of messageSentEvents) {
|
|
721
|
+
const msgTime = dayjs(msg.time);
|
|
722
|
+
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
723
|
+
hasTimely = true;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (hasTimely) break;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!hasTimely) {
|
|
731
|
+
isGhoster = true;
|
|
732
|
+
// Find earliest match time as churn reference point
|
|
733
|
+
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
734
|
+
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
735
|
+
);
|
|
736
|
+
const churnAfter = dayjs(earliestMatch.time);
|
|
737
|
+
|
|
738
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
739
|
+
const evt = events[i];
|
|
740
|
+
if (dayjs(evt.time).isAfter(churnAfter)) {
|
|
741
|
+
if (chance.bool({ likelihood: 70 })) {
|
|
742
|
+
events.splice(i, 1);
|
|
743
|
+
} else if (evt.event === "app opened") {
|
|
744
|
+
evt.ghosted = true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── HOOK 8: OFF-APP RETENTION ───────────────────
|
|
752
|
+
// Users with phone exchange or date in first 14 days: 60% D30 retention
|
|
753
|
+
// Others: lose events after day 30
|
|
754
|
+
if (firstEventTime) {
|
|
755
|
+
const day30 = dayjs(firstEventTime).add(30, "days");
|
|
756
|
+
const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
|
|
757
|
+
|
|
758
|
+
if (hasEarlyMilestone) {
|
|
759
|
+
// Good retention: inject extra events after day 30 to sustain engagement
|
|
760
|
+
const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
|
|
761
|
+
const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
|
|
762
|
+
const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
|
|
763
|
+
|
|
764
|
+
// If they don't have enough post-day30 events, inject some
|
|
765
|
+
if (postDay30Events.length < events.length * 0.3) {
|
|
766
|
+
const retentionCount = Math.floor(events.length * 0.3);
|
|
767
|
+
for (let i = 0; i < retentionCount; i++) {
|
|
768
|
+
const daysAfter = chance.integer({ min: 1, max: 60 });
|
|
769
|
+
const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
|
|
770
|
+
events.push({
|
|
771
|
+
...template,
|
|
772
|
+
time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
773
|
+
user_id: template.user_id,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
// No milestone: churn after day 30
|
|
779
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
780
|
+
const evt = events[i];
|
|
781
|
+
if (dayjs(evt.time).isAfter(day30)) {
|
|
782
|
+
// Keep ~20% of events (remove ~80%) to simulate natural drop-off
|
|
783
|
+
if (chance.bool({ likelihood: 80 })) {
|
|
784
|
+
events.splice(i, 1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return record;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return record;
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
export default config;
|