@bash-app/bash-common 30.314.0 → 30.316.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/dist/__tests__/hostCrmSegments.test.d.ts +2 -0
- package/dist/__tests__/hostCrmSegments.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmSegments.test.js +188 -0
- package/dist/__tests__/hostCrmSegments.test.js.map +1 -0
- package/dist/__tests__/relationshipHealth.test.d.ts +2 -0
- package/dist/__tests__/relationshipHealth.test.d.ts.map +1 -0
- package/dist/__tests__/relationshipHealth.test.js +265 -0
- package/dist/__tests__/relationshipHealth.test.js.map +1 -0
- package/dist/hostCrmAutomation.d.ts +64 -0
- package/dist/hostCrmAutomation.d.ts.map +1 -0
- package/dist/hostCrmAutomation.js +21 -0
- package/dist/hostCrmAutomation.js.map +1 -0
- package/dist/hostCrmInsights.d.ts +39 -0
- package/dist/hostCrmInsights.d.ts.map +1 -0
- package/dist/hostCrmInsights.js +2 -0
- package/dist/hostCrmInsights.js.map +1 -0
- package/dist/hostCrmSegments.d.ts +80 -2
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js +84 -3
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/membershipDefinitions.d.ts +2 -0
- package/dist/membershipDefinitions.d.ts.map +1 -1
- package/dist/membershipDefinitions.js +3 -0
- package/dist/membershipDefinitions.js.map +1 -1
- package/dist/relationshipHealth.d.ts +28 -0
- package/dist/relationshipHealth.d.ts.map +1 -0
- package/dist/relationshipHealth.js +91 -0
- package/dist/relationshipHealth.js.map +1 -0
- package/dist/utils/__tests__/slugUtils.test.js +9 -1
- package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
- package/dist/utils/slugUtils.d.ts +7 -0
- package/dist/utils/slugUtils.d.ts.map +1 -1
- package/dist/utils/slugUtils.js +9 -0
- package/dist/utils/slugUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +126 -1
- package/src/__tests__/hostCrmSegments.test.ts +234 -0
- package/src/__tests__/relationshipHealth.test.ts +370 -0
- package/src/hostCrmAutomation.ts +79 -0
- package/src/hostCrmInsights.ts +39 -0
- package/src/hostCrmSegments.ts +196 -7
- package/src/index.ts +3 -0
- package/src/membershipDefinitions.ts +3 -0
- package/src/relationshipHealth.ts +134 -0
- package/src/utils/__tests__/slugUtils.test.ts +15 -0
- package/src/utils/slugUtils.ts +13 -0
package/src/hostCrmSegments.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { SocialEventCadence } from "@prisma/client";
|
|
2
2
|
|
|
3
|
+
import type {
|
|
4
|
+
RelationshipHealthResult,
|
|
5
|
+
RelationshipHealthStatus,
|
|
6
|
+
} from "./relationshipHealth.js";
|
|
7
|
+
|
|
3
8
|
/** Rule-based CRM segment (stored as JSON on HostCrmSavedSegment.rules). */
|
|
4
9
|
export type HostCrmSegmentRules = {
|
|
5
10
|
tag?: string;
|
|
@@ -14,10 +19,30 @@ export type HostCrmSegmentRules = {
|
|
|
14
19
|
tierTitle?: string;
|
|
15
20
|
/** Match attendee city (case-insensitive substring) */
|
|
16
21
|
city?: string;
|
|
17
|
-
/** Built-in preset id (e.g.
|
|
22
|
+
/** Built-in preset id (e.g. top_spenders) when rules alone are insufficient */
|
|
18
23
|
presetId?: string;
|
|
24
|
+
/** Relationship Health status (New/Steady/Cooling/AtRisk) */
|
|
25
|
+
healthStatus?: RelationshipHealthStatus;
|
|
26
|
+
/** Lifetime spend across host bashes, in cents */
|
|
27
|
+
minTotalSpendCents?: number;
|
|
28
|
+
/** Bought a ticket but never checked in to an ended bash */
|
|
29
|
+
noShow?: boolean;
|
|
30
|
+
/** UI label: "Uses Promo Codes" — redeemed a promo code on a host bash */
|
|
31
|
+
discountResponsive?: boolean;
|
|
32
|
+
/** UI label: "Last-Minute Buyer" — typically buys within 48h of the event */
|
|
33
|
+
fastDecider?: boolean;
|
|
34
|
+
/** UI label: "Series Regular" — attended 2+ events in the same EventSeries */
|
|
35
|
+
seriesLoyalist?: boolean;
|
|
36
|
+
/** Refunded/cancelled a ticket and hasn't been back since */
|
|
37
|
+
refundChurn?: boolean;
|
|
38
|
+
/** UI label: "Brings Friends" — buys multiple tickets per checkout or brings plus-ones */
|
|
39
|
+
bringsGuests?: boolean;
|
|
40
|
+
/** Ticket buyers for any of these bash event ids (e.g. the host's 3 most recently ended bashes — see the last3Events preset) */
|
|
41
|
+
sourceEventIdIn?: string[];
|
|
19
42
|
};
|
|
20
43
|
|
|
44
|
+
export type HostCrmDiscoverySource = "promoter" | "referral" | "direct";
|
|
45
|
+
|
|
21
46
|
export type HostCrmAudienceMember = {
|
|
22
47
|
userId: string | null;
|
|
23
48
|
email: string | null;
|
|
@@ -32,19 +57,41 @@ export type HostCrmAudienceMember = {
|
|
|
32
57
|
reachableEmail: boolean;
|
|
33
58
|
reachableSms: boolean;
|
|
34
59
|
lastAttendedAt: string | null;
|
|
60
|
+
/** First attended bash time, used to derive this attendee's own cadence for Relationship Health. */
|
|
61
|
+
firstAttendedAt: string | null;
|
|
35
62
|
city: string | null;
|
|
36
63
|
tierTitles: string[];
|
|
37
64
|
/** Bash event ids this person attended (for cohort / source filters) */
|
|
38
65
|
sourceEventIds?: string[];
|
|
39
66
|
/** True when row is email-only (guest checkout) without a linked user id */
|
|
40
67
|
emailOnly: boolean;
|
|
68
|
+
/** Lifetime spend across host bashes, in cents (paidUnitPriceCents when set, else tier price). */
|
|
69
|
+
totalSpendCents: number;
|
|
70
|
+
/** Ended bashes where they bought a ticket but never checked in. */
|
|
71
|
+
noShowCount: number;
|
|
72
|
+
/** UI label: "Uses Promo Codes" */
|
|
73
|
+
discountResponsive: boolean;
|
|
74
|
+
/** UI label: "Last-Minute Buyer" — majority of purchases within 48h of the event start */
|
|
75
|
+
fastDecider: boolean;
|
|
76
|
+
/** UI label: "Series Regular" — attended 2+ events sharing an EventSeries */
|
|
77
|
+
seriesLoyalist: boolean;
|
|
78
|
+
/** Refunded/cancelled a ticket on a host bash and hasn't bought again since */
|
|
79
|
+
refundChurn: boolean;
|
|
80
|
+
/** UI label: "Brings Friends" — multi-ticket checkouts, group tiers, or RSVP plus-ones */
|
|
81
|
+
bringsGuests: boolean;
|
|
82
|
+
/** How this attendee was attributed to the host's audience, when known. */
|
|
83
|
+
discoverySource: HostCrmDiscoverySource;
|
|
84
|
+
/** This attendee's own derived cadence in days, or null when there isn't enough history to personalize it. */
|
|
85
|
+
expectedGapDays: number | null;
|
|
86
|
+
/** Predicted next-attendance date (now + expectedGapDays from lastAttendedAt), or null when unknown. */
|
|
87
|
+
nextExpectedAt: string | null;
|
|
41
88
|
};
|
|
42
89
|
|
|
43
|
-
export function applyHostCrmSegmentRules(
|
|
44
|
-
audience:
|
|
90
|
+
export function applyHostCrmSegmentRules<T extends HostCrmAudienceMember>(
|
|
91
|
+
audience: T[],
|
|
45
92
|
rules: HostCrmSegmentRules,
|
|
46
93
|
now: Date = new Date()
|
|
47
|
-
):
|
|
94
|
+
): T[] {
|
|
48
95
|
let rows = audience;
|
|
49
96
|
|
|
50
97
|
if (rules.tag?.trim()) {
|
|
@@ -78,6 +125,11 @@ export function applyHostCrmSegmentRules(
|
|
|
78
125
|
rows = rows.filter((r) => !r.sourceEventIds?.includes(excludeId));
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
if (rules.sourceEventIdIn && rules.sourceEventIdIn.length > 0) {
|
|
129
|
+
const idSet = new Set(rules.sourceEventIdIn);
|
|
130
|
+
rows = rows.filter((r) => r.sourceEventIds?.some((id) => idSet.has(id)));
|
|
131
|
+
}
|
|
132
|
+
|
|
81
133
|
if (rules.tierTitle?.trim()) {
|
|
82
134
|
const tier = rules.tierTitle.trim().toLowerCase();
|
|
83
135
|
rows = rows.filter((r) =>
|
|
@@ -90,6 +142,43 @@ export function applyHostCrmSegmentRules(
|
|
|
90
142
|
rows = rows.filter((r) => (r.city ?? "").toLowerCase().includes(cityNeedle));
|
|
91
143
|
}
|
|
92
144
|
|
|
145
|
+
if (typeof rules.minTotalSpendCents === "number" && rules.minTotalSpendCents > 0) {
|
|
146
|
+
rows = rows.filter((r) => r.totalSpendCents >= rules.minTotalSpendCents!);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (rules.noShow) {
|
|
150
|
+
rows = rows.filter((r) => r.noShowCount > 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (rules.discountResponsive) {
|
|
154
|
+
rows = rows.filter((r) => r.discountResponsive);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (rules.fastDecider) {
|
|
158
|
+
rows = rows.filter((r) => r.fastDecider);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (rules.seriesLoyalist) {
|
|
162
|
+
rows = rows.filter((r) => r.seriesLoyalist);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (rules.refundChurn) {
|
|
166
|
+
rows = rows.filter((r) => r.refundChurn);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (rules.bringsGuests) {
|
|
170
|
+
rows = rows.filter((r) => r.bringsGuests);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (rules.healthStatus) {
|
|
174
|
+
rows = rows.filter((r) => {
|
|
175
|
+
const health = (
|
|
176
|
+
r as HostCrmAudienceMember & { relationshipHealth?: RelationshipHealthResult }
|
|
177
|
+
).relationshipHealth;
|
|
178
|
+
return health?.status === rules.healthStatus;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
93
182
|
return rows;
|
|
94
183
|
}
|
|
95
184
|
|
|
@@ -105,6 +194,12 @@ export const HOST_CRM_PRESET_SEGMENTS: Array<{
|
|
|
105
194
|
description: "People who have been to at least two of your bashes",
|
|
106
195
|
rules: { minEventCount: 2 },
|
|
107
196
|
},
|
|
197
|
+
{
|
|
198
|
+
id: "frequent_guest",
|
|
199
|
+
label: "Frequent guest (3+ bashes)",
|
|
200
|
+
description: "People who have been to at least three of your bashes",
|
|
201
|
+
rules: { minEventCount: 3 },
|
|
202
|
+
},
|
|
108
203
|
{
|
|
109
204
|
id: "lapsed_90",
|
|
110
205
|
label: "Haven't been in 90 days",
|
|
@@ -118,9 +213,103 @@ export const HOST_CRM_PRESET_SEGMENTS: Array<{
|
|
|
118
213
|
rules: { lapsedDays: 180 },
|
|
119
214
|
},
|
|
120
215
|
{
|
|
121
|
-
id: "
|
|
122
|
-
label: "
|
|
123
|
-
description: "
|
|
216
|
+
id: "top_spenders",
|
|
217
|
+
label: "Top spenders",
|
|
218
|
+
description: "Your top quartile of attendees by lifetime spend",
|
|
219
|
+
// Computed at query time from the full audience — see filterHostCrmAudience's presetId branch.
|
|
220
|
+
rules: {},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: "no_show",
|
|
224
|
+
label: "Bought, didn't check in",
|
|
225
|
+
description: "Bought a ticket to a past bash but never checked in",
|
|
226
|
+
rules: { noShow: true },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "uses_promo_codes",
|
|
230
|
+
label: "Uses promo codes",
|
|
231
|
+
description: "Has redeemed a promo code on one of your bashes",
|
|
232
|
+
rules: { discountResponsive: true },
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "last_minute_buyer",
|
|
236
|
+
label: "Last-minute buyer",
|
|
237
|
+
description: "Typically buys within 48 hours of the event",
|
|
238
|
+
rules: { fastDecider: true },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "refund_churn",
|
|
242
|
+
label: "Refunded and hasn't been back",
|
|
243
|
+
description: "Refunded or cancelled a ticket and hasn't returned since",
|
|
244
|
+
rules: { refundChurn: true },
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "series_regular",
|
|
248
|
+
label: "Series regular",
|
|
249
|
+
description: "Attended two or more events in the same series",
|
|
250
|
+
rules: { seriesLoyalist: true },
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "brings_friends",
|
|
254
|
+
label: "Brings friends",
|
|
255
|
+
description: "Usually buys multiple tickets or brings plus-ones",
|
|
256
|
+
rules: { bringsGuests: true },
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: "last3Events",
|
|
260
|
+
label: "Last 3 events",
|
|
261
|
+
description: "People who came to any of your 3 most recently ended bashes",
|
|
262
|
+
// Computed at query time from the host's finished events — see filterHostCrmAudience's presetId branch.
|
|
124
263
|
rules: {},
|
|
125
264
|
},
|
|
126
265
|
];
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* One chronological entry in an attendee's relationship timeline (detail
|
|
269
|
+
* drawer). Deliberately a discriminated union of discrete facts rather than
|
|
270
|
+
* pre-rendered prose: the drawer renders it directly, and any future
|
|
271
|
+
* AI-drafted-message feature can consume the same structured facts to build
|
|
272
|
+
* a prompt without re-deriving them (see `buildAttendeeCrmContext`).
|
|
273
|
+
*/
|
|
274
|
+
export type HostCrmTimelineEntryType =
|
|
275
|
+
| "TicketPurchased"
|
|
276
|
+
| "CheckedIn"
|
|
277
|
+
| "Refunded"
|
|
278
|
+
| "DiscountApplied"
|
|
279
|
+
| "EmailSent"
|
|
280
|
+
| "SmsSent"
|
|
281
|
+
| "NoteAdded"
|
|
282
|
+
| "NoteUpdated"
|
|
283
|
+
| "TagAdded"
|
|
284
|
+
| "TagRemoved";
|
|
285
|
+
|
|
286
|
+
export type HostCrmTimelineEntry = {
|
|
287
|
+
type: HostCrmTimelineEntryType;
|
|
288
|
+
/** ISO timestamp. */
|
|
289
|
+
at: string;
|
|
290
|
+
/** Short human-readable detail (event title, discount code, tag name, etc.). */
|
|
291
|
+
detail?: string;
|
|
292
|
+
/** Rendering-only flag: true when a TicketPurchased entry was for a VIP-titled tier. */
|
|
293
|
+
isVip?: boolean;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/** Drawer-only behavioral badges, kept off the always-visible attendee row. */
|
|
297
|
+
export type HostCrmAttendeeDetailBadges = {
|
|
298
|
+
fastDecider: boolean;
|
|
299
|
+
discountResponsive: boolean;
|
|
300
|
+
seriesLoyalist: boolean;
|
|
301
|
+
refundChurn: boolean;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Full detail payload for one attendee's drawer — the response shape for
|
|
306
|
+
* `GET /attendee-audience/:attendeeUserId/detail`, built by the shared
|
|
307
|
+
* `buildAttendeeCrmContext` function so a future AI compose endpoint can
|
|
308
|
+
* reuse the same assembly without refactoring this contract.
|
|
309
|
+
*/
|
|
310
|
+
export type HostCrmAttendeeDetail = {
|
|
311
|
+
member: HostCrmAudienceMember;
|
|
312
|
+
relationshipHealth: RelationshipHealthResult;
|
|
313
|
+
badges: HostCrmAttendeeDetailBadges;
|
|
314
|
+
timeline: HostCrmTimelineEntry[];
|
|
315
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,9 @@ export * from "./entertainmentNotableVenues.js";
|
|
|
30
30
|
export * from "./productLaunch.js";
|
|
31
31
|
export * from "./accessScreening.js";
|
|
32
32
|
export * from "./hostCrmSegments.js";
|
|
33
|
+
export * from "./hostCrmInsights.js";
|
|
34
|
+
export * from "./hostCrmAutomation.js";
|
|
35
|
+
export * from "./relationshipHealth.js";
|
|
33
36
|
export * from "./hostCrmConstants.js";
|
|
34
37
|
export * from "./userReportTypes.js";
|
|
35
38
|
export * from "./mirroredPrismaEnums.js";
|
|
@@ -807,6 +807,8 @@ export const MEMBERSHIP_FEATURES = {
|
|
|
807
807
|
FEATURED_EVENTS_PAID: 'featured_events_paid',
|
|
808
808
|
BETA_FEATURES: 'beta_features',
|
|
809
809
|
BASIC_NOTIFICATIONS: 'basic_notifications',
|
|
810
|
+
/** Host CRM: predictive insight cards (most-likely-to-return, attendance trend, day-of-week retention). */
|
|
811
|
+
HOST_CRM_INSIGHTS: 'host_crm_insights',
|
|
810
812
|
|
|
811
813
|
// Pro features
|
|
812
814
|
FREE_FEATURED_EVENTS: 'free_featured_events',
|
|
@@ -850,6 +852,7 @@ export function hasFeatureAccess(userTier: MembershipTier, feature: string): boo
|
|
|
850
852
|
case MEMBERSHIP_FEATURES.FEATURED_EVENTS_PAID:
|
|
851
853
|
case MEMBERSHIP_FEATURES.BETA_FEATURES:
|
|
852
854
|
case MEMBERSHIP_FEATURES.BASIC_NOTIFICATIONS:
|
|
855
|
+
case MEMBERSHIP_FEATURES.HOST_CRM_INSIGHTS:
|
|
853
856
|
return tierIndex >= 1; // Creator+
|
|
854
857
|
|
|
855
858
|
case MEMBERSHIP_FEATURES.FREE_FEATURED_EVENTS:
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-attendee Relationship Health — one deterministic, inspectable signal for
|
|
3
|
+
* the Attendee CRM, replacing the narrower "lapsed VIP alert" idea with a
|
|
4
|
+
* status every attendee gets. Deliberately rule-based (not LLM-guessed) so a
|
|
5
|
+
* host can see exactly why a status fired via `reasons`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type RelationshipHealthStatus = "New" | "Steady" | "Cooling" | "AtRisk";
|
|
9
|
+
|
|
10
|
+
export const RELATIONSHIP_HEALTH_LABELS: Record<RelationshipHealthStatus, string> = {
|
|
11
|
+
New: "New",
|
|
12
|
+
Steady: "Steady",
|
|
13
|
+
Cooling: "Cooling",
|
|
14
|
+
AtRisk: "At risk",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RelationshipHealthInputs = {
|
|
18
|
+
eventCount: number;
|
|
19
|
+
/** ISO date of the most recent attended bash, if any. */
|
|
20
|
+
lastAttendedAt: string | null;
|
|
21
|
+
/** ISO date of the first attended bash, if any (used to derive this attendee's own cadence). */
|
|
22
|
+
firstAttendedAt: string | null;
|
|
23
|
+
tags: string[];
|
|
24
|
+
/** ISO date of the most recent host outreach (CRM email/SMS) to this attendee, if any. Display-only input — does not change the bucket. */
|
|
25
|
+
lastOutreachAt: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RelationshipHealthResult = {
|
|
29
|
+
status: RelationshipHealthStatus;
|
|
30
|
+
/** Ordered, human-readable trace of the exact signals behind this status — shown on hover/click, never hidden. */
|
|
31
|
+
reasons: string[];
|
|
32
|
+
daysSinceLastAttended: number | null;
|
|
33
|
+
/** This attendee's own derived cadence in days, or null when there isn't enough history to personalize it. */
|
|
34
|
+
expectedGapDays: number | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_EXPECTED_GAP_DAYS = 60;
|
|
38
|
+
const MIN_EXPECTED_GAP_DAYS = 14;
|
|
39
|
+
const NEW_WINDOW_DAYS = 45;
|
|
40
|
+
const SINGLE_EVENT_COOLING_DAYS = 120;
|
|
41
|
+
const STEADY_RATIO = 1.5;
|
|
42
|
+
const COOLING_RATIO = 3;
|
|
43
|
+
|
|
44
|
+
function isVip(tags: string[]): boolean {
|
|
45
|
+
return tags.some((t) => t.trim().toLowerCase() === "vip");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function daysBetween(later: Date, earlier: Date): number {
|
|
49
|
+
return Math.round((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function plural(n: number): string {
|
|
53
|
+
return n === 1 ? "" : "s";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function computeRelationshipHealth(
|
|
57
|
+
inputs: RelationshipHealthInputs,
|
|
58
|
+
now: Date = new Date()
|
|
59
|
+
): RelationshipHealthResult {
|
|
60
|
+
const reasons: string[] = [];
|
|
61
|
+
const vip = isVip(inputs.tags);
|
|
62
|
+
|
|
63
|
+
if (inputs.eventCount === 0) {
|
|
64
|
+
reasons.push("No attended bashes on record yet");
|
|
65
|
+
return { status: "New", reasons, daysSinceLastAttended: null, expectedGapDays: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lastAttended = inputs.lastAttendedAt ? new Date(inputs.lastAttendedAt) : null;
|
|
69
|
+
const daysSinceLastAttended = lastAttended ? daysBetween(now, lastAttended) : null;
|
|
70
|
+
if (daysSinceLastAttended !== null) {
|
|
71
|
+
reasons.push(
|
|
72
|
+
`Last attended ${daysSinceLastAttended} day${plural(daysSinceLastAttended)} ago`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let status: RelationshipHealthStatus;
|
|
77
|
+
let expectedGapDays: number | null = null;
|
|
78
|
+
|
|
79
|
+
if (inputs.eventCount === 1) {
|
|
80
|
+
reasons.push("Attended 1 bash so far");
|
|
81
|
+
if (daysSinceLastAttended === null || daysSinceLastAttended <= NEW_WINDOW_DAYS) {
|
|
82
|
+
status = "New";
|
|
83
|
+
} else if (daysSinceLastAttended <= SINGLE_EVENT_COOLING_DAYS) {
|
|
84
|
+
status = "Cooling";
|
|
85
|
+
} else {
|
|
86
|
+
status = "AtRisk";
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const first = inputs.firstAttendedAt ? new Date(inputs.firstAttendedAt) : null;
|
|
90
|
+
expectedGapDays = DEFAULT_EXPECTED_GAP_DAYS;
|
|
91
|
+
if (first && lastAttended) {
|
|
92
|
+
const spanDays = daysBetween(lastAttended, first);
|
|
93
|
+
if (spanDays > 0) {
|
|
94
|
+
expectedGapDays = Math.max(
|
|
95
|
+
MIN_EXPECTED_GAP_DAYS,
|
|
96
|
+
Math.round(spanDays / (inputs.eventCount - 1))
|
|
97
|
+
);
|
|
98
|
+
reasons.push(
|
|
99
|
+
`Typically attends every ~${expectedGapDays} days (based on ${inputs.eventCount} bashes)`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (expectedGapDays === DEFAULT_EXPECTED_GAP_DAYS) {
|
|
104
|
+
reasons.push(
|
|
105
|
+
`Using a default ${DEFAULT_EXPECTED_GAP_DAYS}-day cadence — not enough history to personalize yet`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ratio = daysSinceLastAttended === null ? 0 : daysSinceLastAttended / expectedGapDays;
|
|
110
|
+
if (ratio <= STEADY_RATIO) {
|
|
111
|
+
status = "Steady";
|
|
112
|
+
} else if (ratio <= COOLING_RATIO) {
|
|
113
|
+
status = "Cooling";
|
|
114
|
+
} else {
|
|
115
|
+
status = "AtRisk";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (vip && status === "Cooling") {
|
|
120
|
+
status = "AtRisk";
|
|
121
|
+
reasons.push("Tagged VIP — escalated from Cooling");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (inputs.lastOutreachAt) {
|
|
125
|
+
const daysSinceOutreach = daysBetween(now, new Date(inputs.lastOutreachAt));
|
|
126
|
+
reasons.push(
|
|
127
|
+
`Last contacted ${daysSinceOutreach} day${plural(daysSinceOutreach)} ago`
|
|
128
|
+
);
|
|
129
|
+
} else if (status === "Cooling" || status === "AtRisk") {
|
|
130
|
+
reasons.push("No outreach on record");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { status, reasons, daysSinceLastAttended, expectedGapDays };
|
|
134
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
generateUniqueSlug,
|
|
11
11
|
generateBashDetailUrl,
|
|
12
12
|
getPublicBashDetailPath,
|
|
13
|
+
getPublicBashMemoriesPath,
|
|
13
14
|
getBashEventWizardStepPath,
|
|
14
15
|
getBashEventWizardPayoutStepPath,
|
|
15
16
|
parseBashUrlParams,
|
|
@@ -79,6 +80,20 @@ describe("getPublicBashDetailPath", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("getPublicBashMemoriesPath", () => {
|
|
84
|
+
test("nests /memories under the slug detail path", () => {
|
|
85
|
+
expect(getPublicBashMemoriesPath("abc123", "summer-bash")).toBe(
|
|
86
|
+
"/bash/abc123-summer-bash/memories"
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("nests /memories under the id-only path when slug is missing", () => {
|
|
91
|
+
expect(getPublicBashMemoriesPath("abc123", null)).toBe(
|
|
92
|
+
`${BASH_DETAIL_URL}/abc123/memories`
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
82
97
|
describe("getBashEventWizardStepPath", () => {
|
|
83
98
|
test("builds wizard step path", () => {
|
|
84
99
|
expect(getBashEventWizardStepPath("evt-1", 3, 2)).toBe(
|
package/src/utils/slugUtils.ts
CHANGED
|
@@ -52,6 +52,19 @@ export function getPublicBashDetailPath(
|
|
|
52
52
|
return `${BASH_DETAIL_URL}/${bashEventId}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Public, shareable Memories recap path for a bash — nested under the bash
|
|
57
|
+
* detail path so it inherits the same id/slug parsing (`/bash/{id}-{slug}/memories`).
|
|
58
|
+
* No-login-required by design: the whole point of Memories is that a shared
|
|
59
|
+
* link works for people who aren't signed in yet.
|
|
60
|
+
*/
|
|
61
|
+
export function getPublicBashMemoriesPath(
|
|
62
|
+
bashEventId: string,
|
|
63
|
+
slug: string | null | undefined
|
|
64
|
+
): string {
|
|
65
|
+
return `${getPublicBashDetailPath(bashEventId, slug)}/memories`;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/** Wizard URL with explicit step (see bash-app `/bash-event-wizard/$id/$major/$sub`). */
|
|
56
69
|
export function getBashEventWizardStepPath(
|
|
57
70
|
bashEventId: string,
|