@bash-app/bash-common 30.432.0 → 30.434.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.
Files changed (164) hide show
  1. package/dist/__tests__/appleIapProducts.test.js +1 -0
  2. package/dist/__tests__/appleIapProducts.test.js.map +1 -1
  3. package/dist/__tests__/bashSectionTitleForBashType.test.js +2 -0
  4. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  5. package/dist/__tests__/discoveryScope.test.js +9 -1
  6. package/dist/__tests__/discoveryScope.test.js.map +1 -1
  7. package/dist/__tests__/promoCartEngine.test.d.ts +2 -0
  8. package/dist/__tests__/promoCartEngine.test.d.ts.map +1 -0
  9. package/dist/__tests__/promoCartEngine.test.js +174 -0
  10. package/dist/__tests__/promoCartEngine.test.js.map +1 -0
  11. package/dist/__tests__/promoCodeRedemption.test.d.ts +2 -0
  12. package/dist/__tests__/promoCodeRedemption.test.d.ts.map +1 -0
  13. package/dist/__tests__/promoCodeRedemption.test.js +103 -0
  14. package/dist/__tests__/promoCodeRedemption.test.js.map +1 -0
  15. package/dist/__tests__/promoCodeScope.test.d.ts +2 -0
  16. package/dist/__tests__/promoCodeScope.test.d.ts.map +1 -0
  17. package/dist/__tests__/promoCodeScope.test.js +84 -0
  18. package/dist/__tests__/promoCodeScope.test.js.map +1 -0
  19. package/dist/__tests__/statusEnums.test.js +1 -0
  20. package/dist/__tests__/statusEnums.test.js.map +1 -1
  21. package/dist/appleIapProducts.d.ts +12 -2
  22. package/dist/appleIapProducts.d.ts.map +1 -1
  23. package/dist/appleIapProducts.js +13 -0
  24. package/dist/appleIapProducts.js.map +1 -1
  25. package/dist/definitions.d.ts +3 -1
  26. package/dist/definitions.d.ts.map +1 -1
  27. package/dist/definitions.js +4 -2
  28. package/dist/definitions.js.map +1 -1
  29. package/dist/discoveryScope.d.ts +6 -6
  30. package/dist/discoveryScope.d.ts.map +1 -1
  31. package/dist/discoveryScope.js +25 -16
  32. package/dist/discoveryScope.js.map +1 -1
  33. package/dist/extendedSchemas.d.ts +1 -0
  34. package/dist/extendedSchemas.d.ts.map +1 -1
  35. package/dist/extendedSchemas.js.map +1 -1
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
  41. package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
  42. package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
  43. package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
  44. package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
  45. package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
  46. package/dist/intros/__tests__/psychExperiment.test.js +63 -0
  47. package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
  48. package/dist/intros/__tests__/reasons.test.d.ts +2 -0
  49. package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
  50. package/dist/intros/__tests__/reasons.test.js +72 -0
  51. package/dist/intros/__tests__/reasons.test.js.map +1 -0
  52. package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
  53. package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
  54. package/dist/intros/__tests__/sharedNotes.test.js +19 -0
  55. package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
  56. package/dist/intros/__tests__/transitions.test.d.ts +2 -0
  57. package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
  58. package/dist/intros/__tests__/transitions.test.js +43 -0
  59. package/dist/intros/__tests__/transitions.test.js.map +1 -0
  60. package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
  61. package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
  62. package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
  63. package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
  64. package/dist/intros/age.d.ts +4 -0
  65. package/dist/intros/age.d.ts.map +1 -0
  66. package/dist/intros/age.js +14 -0
  67. package/dist/intros/age.js.map +1 -0
  68. package/dist/intros/attendance.d.ts +11 -0
  69. package/dist/intros/attendance.d.ts.map +1 -0
  70. package/dist/intros/attendance.js +27 -0
  71. package/dist/intros/attendance.js.map +1 -0
  72. package/dist/intros/attributeCatalog.d.ts +17 -0
  73. package/dist/intros/attributeCatalog.d.ts.map +1 -0
  74. package/dist/intros/attributeCatalog.js +96 -0
  75. package/dist/intros/attributeCatalog.js.map +1 -0
  76. package/dist/intros/index.d.ts +10 -0
  77. package/dist/intros/index.d.ts.map +1 -0
  78. package/dist/intros/index.js +10 -0
  79. package/dist/intros/index.js.map +1 -0
  80. package/dist/intros/intents.d.ts +8 -0
  81. package/dist/intros/intents.d.ts.map +1 -0
  82. package/dist/intros/intents.js +44 -0
  83. package/dist/intros/intents.js.map +1 -0
  84. package/dist/intros/psychExperiment.d.ts +29 -0
  85. package/dist/intros/psychExperiment.d.ts.map +1 -0
  86. package/dist/intros/psychExperiment.js +82 -0
  87. package/dist/intros/psychExperiment.js.map +1 -0
  88. package/dist/intros/reasons.d.ts +38 -0
  89. package/dist/intros/reasons.d.ts.map +1 -0
  90. package/dist/intros/reasons.js +162 -0
  91. package/dist/intros/reasons.js.map +1 -0
  92. package/dist/intros/sharedNotes.d.ts +10 -0
  93. package/dist/intros/sharedNotes.d.ts.map +1 -0
  94. package/dist/intros/sharedNotes.js +56 -0
  95. package/dist/intros/sharedNotes.js.map +1 -0
  96. package/dist/intros/transitions.d.ts +29 -0
  97. package/dist/intros/transitions.d.ts.map +1 -0
  98. package/dist/intros/transitions.js +128 -0
  99. package/dist/intros/transitions.js.map +1 -0
  100. package/dist/intros/validation.d.ts +50 -0
  101. package/dist/intros/validation.d.ts.map +1 -0
  102. package/dist/intros/validation.js +212 -0
  103. package/dist/intros/validation.js.map +1 -0
  104. package/dist/mirroredPrismaEnums.d.ts +1 -0
  105. package/dist/mirroredPrismaEnums.d.ts.map +1 -1
  106. package/dist/mirroredPrismaEnums.js +1 -0
  107. package/dist/mirroredPrismaEnums.js.map +1 -1
  108. package/dist/promoCartEngine.d.ts +55 -0
  109. package/dist/promoCartEngine.d.ts.map +1 -0
  110. package/dist/promoCartEngine.js +135 -0
  111. package/dist/promoCartEngine.js.map +1 -0
  112. package/dist/promoCodeRedemption.d.ts +44 -0
  113. package/dist/promoCodeRedemption.d.ts.map +1 -0
  114. package/dist/promoCodeRedemption.js +81 -0
  115. package/dist/promoCodeRedemption.js.map +1 -0
  116. package/dist/promoCodeScope.d.ts +91 -0
  117. package/dist/promoCodeScope.d.ts.map +1 -0
  118. package/dist/promoCodeScope.js +102 -0
  119. package/dist/promoCodeScope.js.map +1 -0
  120. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +8 -1
  121. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -1
  122. package/dist/utils/__tests__/paymentUtils.test.js +87 -4
  123. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  124. package/dist/utils/paymentUtils.d.ts +30 -5
  125. package/dist/utils/paymentUtils.d.ts.map +1 -1
  126. package/dist/utils/paymentUtils.js +43 -11
  127. package/dist/utils/paymentUtils.js.map +1 -1
  128. package/package.json +1 -1
  129. package/prisma/schema.prisma +427 -6
  130. package/src/__tests__/appleIapProducts.test.ts +3 -0
  131. package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
  132. package/src/__tests__/discoveryScope.test.ts +22 -1
  133. package/src/__tests__/promoCartEngine.test.ts +201 -0
  134. package/src/__tests__/promoCodeRedemption.test.ts +134 -0
  135. package/src/__tests__/promoCodeScope.test.ts +119 -0
  136. package/src/__tests__/statusEnums.test.ts +1 -0
  137. package/src/appleIapProducts.ts +29 -2
  138. package/src/definitions.ts +4 -2
  139. package/src/discoveryScope.ts +30 -20
  140. package/src/extendedSchemas.ts +1 -0
  141. package/src/index.ts +4 -0
  142. package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
  143. package/src/intros/__tests__/psychExperiment.test.ts +99 -0
  144. package/src/intros/__tests__/reasons.test.ts +122 -0
  145. package/src/intros/__tests__/sharedNotes.test.ts +20 -0
  146. package/src/intros/__tests__/transitions.test.ts +61 -0
  147. package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
  148. package/src/intros/age.ts +13 -0
  149. package/src/intros/attendance.ts +33 -0
  150. package/src/intros/attributeCatalog.ts +113 -0
  151. package/src/intros/index.ts +9 -0
  152. package/src/intros/intents.ts +49 -0
  153. package/src/intros/psychExperiment.ts +130 -0
  154. package/src/intros/reasons.ts +187 -0
  155. package/src/intros/sharedNotes.ts +59 -0
  156. package/src/intros/transitions.ts +172 -0
  157. package/src/intros/validation.ts +248 -0
  158. package/src/mirroredPrismaEnums.ts +1 -0
  159. package/src/promoCartEngine.ts +193 -0
  160. package/src/promoCodeRedemption.ts +123 -0
  161. package/src/promoCodeScope.ts +180 -0
  162. package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +8 -0
  163. package/src/utils/__tests__/paymentUtils.test.ts +100 -6
  164. package/src/utils/paymentUtils.ts +56 -13
@@ -0,0 +1,49 @@
1
+ export const INTROS_INTENTS = [
2
+ "Friends",
3
+ "Professional",
4
+ "MentorSeek",
5
+ "MentorOffer",
6
+ "Collaborator",
7
+ "Romance",
8
+ ] as const;
9
+
10
+ export type IntrosIntent = (typeof INTROS_INTENTS)[number];
11
+
12
+ export const INTROS_INTENT_LABELS: Record<IntrosIntent, string> = {
13
+ Friends: "Making friends",
14
+ Professional: "Professional networking",
15
+ MentorSeek: "Finding a mentor",
16
+ MentorOffer: "Offering mentorship",
17
+ Collaborator: "Finding collaborators",
18
+ Romance: "Romance",
19
+ };
20
+
21
+ export function isIntrosIntent(value: string): value is IntrosIntent {
22
+ return (INTROS_INTENTS as readonly string[]).includes(value);
23
+ }
24
+
25
+ /** Complementary intents. Romance only matches Romance. */
26
+ export function intentsComplement(
27
+ a: readonly string[],
28
+ b: readonly string[]
29
+ ): IntrosIntent | null {
30
+ const left = new Set(a.filter(isIntrosIntent));
31
+ const right = new Set(b.filter(isIntrosIntent));
32
+ if (left.has("Romance") && right.has("Romance")) return "Romance";
33
+ if (left.has("MentorSeek") && right.has("MentorOffer")) return "MentorSeek";
34
+ if (left.has("MentorOffer") && right.has("MentorSeek")) return "MentorOffer";
35
+ if (
36
+ (left.has("Collaborator") &&
37
+ (right.has("Collaborator") || right.has("Professional"))) ||
38
+ (left.has("Professional") && right.has("Collaborator"))
39
+ ) {
40
+ return "Collaborator";
41
+ }
42
+ if (left.has("Professional") && right.has("Professional")) return "Professional";
43
+ if (left.has("Friends") && right.has("Friends")) return "Friends";
44
+ return null;
45
+ }
46
+
47
+ export function isRomanceIntent(intent: string | null | undefined): boolean {
48
+ return intent === "Romance";
49
+ }
@@ -0,0 +1,130 @@
1
+ export type PsychAttributeConsent = {
2
+ kind: string;
3
+ allowExperimentalMatching: boolean;
4
+ value?: unknown;
5
+ };
6
+
7
+ export type PsychExperimentInput = {
8
+ experimentConsent: boolean;
9
+ attributes: readonly PsychAttributeConsent[];
10
+ };
11
+
12
+ export type ExperimentContribution = {
13
+ /** Privacy-safe codes. Never a score, type name, or theme name. */
14
+ codes: string[];
15
+ /** 0–2. Reorders people who already matched. It is not a compatibility score. */
16
+ rankBoost: number;
17
+ };
18
+
19
+ const EMPTY: ExperimentContribution = { codes: [], rankBoost: 0 };
20
+ const EXTRAVERSION_NEAR = 20;
21
+
22
+ function usable(person: PsychExperimentInput): PsychAttributeConsent[] {
23
+ if (!person.experimentConsent) return [];
24
+ return person.attributes.filter(
25
+ (attribute) => attribute.allowExperimentalMatching && attribute.kind !== "HumanDesign"
26
+ );
27
+ }
28
+
29
+ function kind(person: PsychAttributeConsent[], name: string): PsychAttributeConsent | undefined {
30
+ return person.find((attribute) => attribute.kind === name);
31
+ }
32
+
33
+ function extraversion(value: unknown): number | null {
34
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
35
+ const score = (value as Record<string, unknown>).extraversion;
36
+ return typeof score === "number" ? score : null;
37
+ }
38
+
39
+ function themes(value: unknown): string[] {
40
+ if (!Array.isArray(value)) return [];
41
+ return value.filter((item): item is string => typeof item === "string");
42
+ }
43
+
44
+ /**
45
+ * Separate from baseline intent and topic matching.
46
+ * Uses only attributes both people explicitly allowed, and only as a small reorder.
47
+ * Human Design is ignored. No score is returned.
48
+ */
49
+ export function experimentContribution(
50
+ a: PsychExperimentInput,
51
+ b: PsychExperimentInput
52
+ ): ExperimentContribution {
53
+ const left = usable(a);
54
+ const right = usable(b);
55
+ if (left.length === 0 || right.length === 0) return EMPTY;
56
+
57
+ const signals: string[] = [];
58
+
59
+ const leftExtra = extraversion(kind(left, "BigFive")?.value);
60
+ const rightExtra = extraversion(kind(right, "BigFive")?.value);
61
+ if (leftExtra != null && rightExtra != null && Math.abs(leftExtra - rightExtra) <= EXTRAVERSION_NEAR) {
62
+ signals.push("PSYCH_SIGNAL:similar_social_energy");
63
+ }
64
+
65
+ const leftLocus = kind(left, "LocusOfControl")?.value;
66
+ const rightLocus = kind(right, "LocusOfControl")?.value;
67
+ if (
68
+ (leftLocus === "internal" || leftLocus === "external") &&
69
+ leftLocus === rightLocus
70
+ ) {
71
+ signals.push("PSYCH_SIGNAL:similar_outlook");
72
+ }
73
+
74
+ const sharedTheme = themes(kind(left, "CliftonStrengths")?.value).some((theme) =>
75
+ themes(kind(right, "CliftonStrengths")?.value).includes(theme)
76
+ );
77
+ if (sharedTheme) signals.push("PSYCH_SIGNAL:shared_theme");
78
+
79
+ const leftType = kind(left, "MyersBriggs")?.value;
80
+ const rightType = kind(right, "MyersBriggs")?.value;
81
+ if (
82
+ typeof leftType === "string" &&
83
+ typeof rightType === "string" &&
84
+ leftType.length === 4 &&
85
+ rightType.length === 4 &&
86
+ leftType[0]?.toUpperCase() === rightType[0]?.toUpperCase()
87
+ ) {
88
+ signals.push("PSYCH_SIGNAL:self_reported_social_style");
89
+ }
90
+
91
+ const leftEi = kind(left, "EmotionalIntelligence")?.value;
92
+ const rightEi = kind(right, "EmotionalIntelligence")?.value;
93
+ if (typeof leftEi === "number" && typeof rightEi === "number" && Math.abs(leftEi - rightEi) <= 1) {
94
+ signals.push("PSYCH_SIGNAL:self_reported_emotional_note");
95
+ }
96
+
97
+ if (signals.length === 0) return EMPTY;
98
+ return { codes: ["PSYCH_SHARED", ...signals], rankBoost: Math.min(2, signals.length) };
99
+ }
100
+
101
+ /** A shared experimental note. Never includes scores, types, or names. */
102
+ export function psychExperimentCode(
103
+ a: PsychExperimentInput,
104
+ b: PsychExperimentInput
105
+ ): "PSYCH_SHARED" | null {
106
+ return experimentContribution(a, b).rankBoost > 0 ? "PSYCH_SHARED" : null;
107
+ }
108
+
109
+ export function appendPsychExperiment(
110
+ codes: readonly string[],
111
+ a: PsychExperimentInput,
112
+ b: PsychExperimentInput,
113
+ enabled: boolean
114
+ ): string[] {
115
+ if (!enabled) return [...codes];
116
+ return [...codes, ...experimentContribution(a, b).codes];
117
+ }
118
+
119
+ export function rankWithExperiment<T>(
120
+ candidates: readonly T[],
121
+ contributionFor: (item: T) => ExperimentContribution,
122
+ enabled: boolean
123
+ ): Array<{ item: T; contribution: ExperimentContribution }> {
124
+ if (!enabled) {
125
+ return candidates.map((item) => ({ item, contribution: EMPTY }));
126
+ }
127
+ return candidates
128
+ .map((item) => ({ item, contribution: contributionFor(item) }))
129
+ .sort((left, right) => right.contribution.rankBoost - left.contribution.rankBoost);
130
+ }
@@ -0,0 +1,187 @@
1
+ import { ageInYears } from "./age.js";
2
+ import { intentsComplement, type IntrosIntent } from "./intents.js";
3
+
4
+ export type MatchProfile = {
5
+ intents: readonly string[];
6
+ topics: readonly string[];
7
+ work: readonly string[];
8
+ };
9
+
10
+ export type RomanceFit = {
11
+ consented: boolean;
12
+ dob: Date | string | null;
13
+ gender: string | null;
14
+ seekingGenders: readonly string[];
15
+ ageMin: number | null;
16
+ ageMax: number | null;
17
+ };
18
+
19
+ function ageOf(dob: Date | string | null, now: Date): number | null {
20
+ if (!dob) return null;
21
+ const date = dob instanceof Date ? dob : new Date(dob);
22
+ if (Number.isNaN(date.getTime())) return null;
23
+ return ageInYears(date, now);
24
+ }
25
+
26
+ function ageFits(seeker: RomanceFit, other: RomanceFit, now: Date): boolean {
27
+ if (seeker.ageMin == null && seeker.ageMax == null) return true;
28
+ const age = ageOf(other.dob, now);
29
+ if (age == null) return false;
30
+ if (seeker.ageMin != null && age < seeker.ageMin) return false;
31
+ if (seeker.ageMax != null && age > seeker.ageMax) return false;
32
+ return true;
33
+ }
34
+
35
+ function genderFits(seeker: RomanceFit, other: RomanceFit): boolean {
36
+ if (seeker.seekingGenders.length === 0) return true;
37
+ if (!other.gender) return false;
38
+ return seeker.seekingGenders.includes(other.gender);
39
+ }
40
+
41
+ /** Both consented, and each person's age and gender fit the other's limits when those limits are set. */
42
+ export function romancePairFits(a: RomanceFit, b: RomanceFit, now = new Date()): boolean {
43
+ if (!a.consented || !b.consented) return false;
44
+ return ageFits(a, b, now) && ageFits(b, a, now) && genderFits(a, b) && genderFits(b, a);
45
+ }
46
+
47
+ export type MatchReason = {
48
+ intent: IntrosIntent;
49
+ codes: string[];
50
+ };
51
+
52
+ function overlap(a: readonly string[], b: readonly string[]): string[] {
53
+ const right = new Set(b.map((item) => item.toLowerCase()));
54
+ return a.filter((item) => right.has(item.toLowerCase())).slice(0, 2);
55
+ }
56
+
57
+ /** Global matches need a specific reason. Same broad intent is not enough. Romance is specific when both people fit. */
58
+ export function pairMatchReason(
59
+ a: MatchProfile,
60
+ b: MatchProfile,
61
+ romance?: { a: RomanceFit; b: RomanceFit },
62
+ now = new Date()
63
+ ): MatchReason | null {
64
+ const intent = intentsComplement(a.intents, b.intents);
65
+ if (!intent) return null;
66
+ if (intent === "Romance") {
67
+ if (!romance || !romancePairFits(romance.a, romance.b, now)) return null;
68
+ return { intent, codes: ["INTENT:Romance"] };
69
+ }
70
+ const topics = overlap(a.topics, b.topics);
71
+ const work = overlap(a.work, b.work);
72
+ const specific =
73
+ intent === "MentorSeek" ||
74
+ intent === "MentorOffer" ||
75
+ topics.length > 0 ||
76
+ work.length > 0;
77
+ if (!specific) return null;
78
+ return {
79
+ intent,
80
+ codes: [
81
+ ...topics.map((topic) => `SHARED_TOPIC:${topic}`),
82
+ ...work.map((item) => `SHARED_WORK:${item}`),
83
+ `INTENT:${intent}`,
84
+ ],
85
+ };
86
+ }
87
+
88
+ export function eventScopedReason(
89
+ a: MatchProfile,
90
+ b: MatchProfile,
91
+ romanceAllowed: boolean,
92
+ romance?: { a: RomanceFit; b: RomanceFit },
93
+ now = new Date()
94
+ ): MatchReason | null {
95
+ const intent = intentsComplement(a.intents, b.intents);
96
+ if (!intent) return null;
97
+ if (intent === "Romance" && (!romanceAllowed || (romance != null && !romancePairFits(romance.a, romance.b, now)))) {
98
+ return null;
99
+ }
100
+ const topics = overlap(a.topics, b.topics);
101
+ const work = overlap(a.work, b.work);
102
+ return {
103
+ intent,
104
+ codes: [
105
+ ...topics.map((topic) => `SHARED_TOPIC:${topic}`),
106
+ ...work.map((item) => `SHARED_WORK:${item}`),
107
+ `INTENT:${intent}`,
108
+ ],
109
+ };
110
+ }
111
+
112
+ /** Higher means a more specific reason. A broad intent still matches. */
113
+ export function eventReasonSpecificity(reason: MatchReason): number {
114
+ if (reason.intent === "MentorSeek" || reason.intent === "MentorOffer" || reason.intent === "Romance") {
115
+ return 3;
116
+ }
117
+ if (reason.codes.some((code) => code.startsWith("SHARED_TOPIC:") || code.startsWith("SHARED_WORK:"))) {
118
+ return 2;
119
+ }
120
+ return 1;
121
+ }
122
+
123
+ function baselineHint(codes: readonly string[]): string | null {
124
+ const topic = codes.find((code) => code.startsWith("SHARED_TOPIC:"));
125
+ if (topic) return `You both mentioned ${topic.slice("SHARED_TOPIC:".length)}.`;
126
+ const work = codes.find((code) => code.startsWith("SHARED_WORK:"));
127
+ if (work) return `You both listed ${work.slice("SHARED_WORK:".length)}.`;
128
+ const intent = codes.find((code) => code.startsWith("INTENT:"))?.slice("INTENT:".length);
129
+ if (intent === "MentorSeek" || intent === "MentorOffer") {
130
+ return "A mentor and someone looking for one.";
131
+ }
132
+ if (intent === "Collaborator") return "You're both open to collaborating.";
133
+ if (intent === "Professional") return "You're both open to meeting people in your field.";
134
+ if (intent === "Friends") return "You're both open to making friends here.";
135
+ if (intent === "Romance") return "You both turned on romance.";
136
+ return null;
137
+ }
138
+
139
+ function experimentHint(codes: readonly string[]): string | null {
140
+ if (codes.includes("PSYCH_SIGNAL:similar_social_energy")) {
141
+ return "You both allowed a note about social energy. It is not a compatibility score.";
142
+ }
143
+ if (codes.includes("PSYCH_SIGNAL:similar_outlook")) {
144
+ return "You both allowed a note about how you approach situations. It is not a compatibility score.";
145
+ }
146
+ if (codes.includes("PSYCH_SIGNAL:shared_theme")) {
147
+ return "You both allowed a shared strengths theme. It is not a compatibility score.";
148
+ }
149
+ if (codes.includes("PSYCH_SIGNAL:self_reported_social_style")) {
150
+ return "You both allowed a self-reported social-style note. It is not a compatibility score.";
151
+ }
152
+ if (codes.includes("PSYCH_SIGNAL:self_reported_emotional_note")) {
153
+ return "You both allowed a self-reported emotional note. It is not a compatibility score.";
154
+ }
155
+ if (codes.includes("PSYCH_SHARED")) {
156
+ return "You both chose to include a personality note. It is not a compatibility score.";
157
+ }
158
+ return null;
159
+ }
160
+
161
+ export function introHint(codes: readonly string[]): string {
162
+ const baseline = baselineHint(codes);
163
+ const experiment = experimentHint(codes);
164
+ if (baseline && experiment) return `${baseline} ${experiment}`;
165
+ return baseline ?? experiment ?? "Bash thinks you might get along at this bash.";
166
+ }
167
+
168
+ export function rankEventForPair(
169
+ events: readonly { id: string; eventType: string; city: string | null }[],
170
+ codes: readonly string[]
171
+ ): string | null {
172
+ if (events.length === 0) return null;
173
+ const needles = codes
174
+ .filter((code) => code.startsWith("SHARED_TOPIC:") || code.startsWith("SHARED_WORK:"))
175
+ .map((code) => code.split(":").slice(1).join(":").toLowerCase());
176
+ let best = events[0];
177
+ let bestScore = -1;
178
+ for (const event of events) {
179
+ const haystack = `${event.eventType} ${event.city ?? ""}`.toLowerCase();
180
+ const score = needles.reduce((sum, needle) => sum + (haystack.includes(needle) ? 2 : 0), 0);
181
+ if (score > bestScore) {
182
+ best = event;
183
+ bestScore = score;
184
+ }
185
+ }
186
+ return best?.id ?? null;
187
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ attributeEvidenceTier,
3
+ isIntrosAttributeKind,
4
+ type IntrosAttributeKind,
5
+ } from "./attributeCatalog.js";
6
+
7
+ export type SharedPsychNote = {
8
+ kind: IntrosAttributeKind;
9
+ label: string;
10
+ tier: "Research-backed construct" | "Self-reported framework" | "Exploratory";
11
+ text: string;
12
+ };
13
+
14
+ const LABELS: Record<IntrosAttributeKind, string> = {
15
+ BigFive: "Big Five",
16
+ MyersBriggs: "Myers-Briggs",
17
+ LocusOfControl: "Locus of control",
18
+ CliftonStrengths: "CliftonStrengths",
19
+ EmotionalIntelligence: "Emotional intelligence",
20
+ HumanDesign: "Human Design",
21
+ };
22
+
23
+ function tierLabel(kind: IntrosAttributeKind): SharedPsychNote["tier"] {
24
+ const tier = attributeEvidenceTier(kind);
25
+ if (tier === "ResearchBackedConstruct") return "Research-backed construct";
26
+ if (tier === "Exploratory") return "Exploratory";
27
+ return "Self-reported framework";
28
+ }
29
+
30
+ function bigFiveText(value: unknown): string | null {
31
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
32
+ const record = value;
33
+ const keys = ["openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism"] as const;
34
+ const parts: string[] = [];
35
+ for (const key of keys) {
36
+ const score = Reflect.get(record, key);
37
+ if (typeof score !== "number") return null;
38
+ const label = key.charAt(0).toUpperCase() + key.slice(1);
39
+ parts.push(`${label} ${score}`);
40
+ }
41
+ return parts.join(", ");
42
+ }
43
+
44
+ /** A note the owner allowed a revealed partner to read. Not a compatibility score. */
45
+ export function describeSharedNote(kind: string, value: unknown): SharedPsychNote | null {
46
+ if (!isIntrosAttributeKind(kind)) return null;
47
+ let text: string | null = null;
48
+ if (kind === "BigFive") text = bigFiveText(value);
49
+ else if (kind === "MyersBriggs" && typeof value === "string") text = value;
50
+ else if (kind === "LocusOfControl" && typeof value === "string") text = value;
51
+ else if (kind === "EmotionalIntelligence" && typeof value === "number") text = `Self-reported ${value} of 5`;
52
+ else if (kind === "HumanDesign" && typeof value === "string") text = value;
53
+ else if (kind === "CliftonStrengths" && Array.isArray(value)) {
54
+ const themes = value.filter((item): item is string => typeof item === "string");
55
+ text = themes.length > 0 ? themes.join(", ") : null;
56
+ }
57
+ if (!text) return null;
58
+ return { kind, label: LABELS[kind], tier: tierLabel(kind), text };
59
+ }
@@ -0,0 +1,172 @@
1
+ export const INTRODUCTION_STATES = ["Open", "Mutual", "Revealed", "Closed"] as const;
2
+ export type IntroductionState = (typeof INTRODUCTION_STATES)[number];
3
+
4
+ export const CLOSED_REASONS = [
5
+ "Declined",
6
+ "Withdrawn",
7
+ "Expired",
8
+ "NotRegistered",
9
+ "Blocked",
10
+ "EventCancelled",
11
+ "IntrosDisabled",
12
+ "ConsentRevoked",
13
+ ] as const;
14
+ export type IntroductionClosedReason = (typeof CLOSED_REASONS)[number];
15
+
16
+ export const PARTICIPANT_ACTIONS = [
17
+ "interested",
18
+ "sounds_good",
19
+ "decline",
20
+ "postpone",
21
+ "withdraw",
22
+ "hide_person",
23
+ "mute",
24
+ "unmute",
25
+ "say_hi",
26
+ "facilitation_ok",
27
+ "request_early_reveal",
28
+ "approve_early_reveal",
29
+ "decline_early_reveal",
30
+ "cancel_early_reveal",
31
+ ] as const;
32
+
33
+ export type ParticipantAction = (typeof PARTICIPANT_ACTIONS)[number];
34
+ export type IntroSide = "low" | "high";
35
+
36
+ export interface TransitionIntro {
37
+ source: "BashSuggested" | "Warm";
38
+ mode: "Mystery" | "Known";
39
+ state: IntroductionState;
40
+ closedReason: IntroductionClosedReason | null;
41
+ interestLow: string;
42
+ interestHigh: string;
43
+ }
44
+
45
+ export type TransitionResult =
46
+ | {
47
+ ok: true;
48
+ state: IntroductionState;
49
+ closedReason: IntroductionClosedReason | null;
50
+ interestLow: string;
51
+ interestHigh: string;
52
+ revealNow: boolean;
53
+ }
54
+ | { ok: false; error: string };
55
+
56
+ function withInterest(
57
+ intro: TransitionIntro,
58
+ side: IntroSide,
59
+ value: string
60
+ ): Pick<TransitionIntro, "interestLow" | "interestHigh"> {
61
+ return side === "low"
62
+ ? { interestLow: value, interestHigh: intro.interestHigh }
63
+ : { interestLow: intro.interestLow, interestHigh: value };
64
+ }
65
+
66
+ function unchanged(intro: TransitionIntro): TransitionResult {
67
+ return {
68
+ ok: true,
69
+ state: intro.state,
70
+ closedReason: intro.closedReason,
71
+ interestLow: intro.interestLow,
72
+ interestHigh: intro.interestHigh,
73
+ revealNow: false,
74
+ };
75
+ }
76
+
77
+ export function applyIntroductionAction(
78
+ intro: TransitionIntro,
79
+ side: IntroSide,
80
+ action: ParticipantAction
81
+ ): TransitionResult {
82
+ const finished = intro.state === "Revealed" || intro.state === "Closed";
83
+ if (finished && action !== "say_hi" && action !== "hide_person") {
84
+ return { ok: false, error: "This intro is already finished" };
85
+ }
86
+
87
+ if (action === "withdraw" || action === "decline") {
88
+ if (intro.state === "Revealed") {
89
+ return { ok: false, error: "This intro is already finished" };
90
+ }
91
+ return {
92
+ ok: true,
93
+ state: "Closed",
94
+ closedReason: action === "decline" ? "Declined" : "Withdrawn",
95
+ ...withInterest(intro, side, action === "decline" ? "Declined" : "Withdrawn"),
96
+ revealNow: false,
97
+ };
98
+ }
99
+
100
+ if (action === "postpone") {
101
+ if (intro.state !== "Open") return { ok: false, error: "You can only postpone an open intro" };
102
+ return {
103
+ ok: true,
104
+ state: "Open",
105
+ closedReason: null,
106
+ ...withInterest(intro, side, "Postponed"),
107
+ revealNow: false,
108
+ };
109
+ }
110
+
111
+ if (action === "interested") {
112
+ if (intro.source !== "BashSuggested") {
113
+ return { ok: false, error: "This intro uses a different response" };
114
+ }
115
+ if (intro.state !== "Open") return { ok: false, error: "This intro is no longer open" };
116
+ const interests = withInterest(intro, side, "Interested");
117
+ const both = interests.interestLow === "Interested" && interests.interestHigh === "Interested";
118
+ return {
119
+ ok: true,
120
+ state: both ? "Mutual" : "Open",
121
+ closedReason: null,
122
+ ...interests,
123
+ revealNow: false,
124
+ };
125
+ }
126
+
127
+ if (action === "sounds_good") {
128
+ if (intro.source !== "Warm") return { ok: false, error: "This intro uses a different response" };
129
+ if (intro.state !== "Open") return { ok: false, error: "This intro is no longer open" };
130
+ const interests = withInterest(intro, side, "Interested");
131
+ const both = interests.interestLow === "Interested" && interests.interestHigh === "Interested";
132
+ return {
133
+ ok: true,
134
+ state: both ? "Revealed" : "Open",
135
+ closedReason: null,
136
+ ...interests,
137
+ revealNow: both,
138
+ };
139
+ }
140
+
141
+ if (action === "say_hi") {
142
+ if (intro.state !== "Revealed") {
143
+ return { ok: false, error: "Say hi is available after you both can see each other" };
144
+ }
145
+ return unchanged(intro);
146
+ }
147
+
148
+ if (action === "mute" || action === "unmute" || action === "hide_person" || action === "facilitation_ok") {
149
+ return unchanged(intro);
150
+ }
151
+
152
+ if (
153
+ action === "request_early_reveal" ||
154
+ action === "approve_early_reveal" ||
155
+ action === "decline_early_reveal" ||
156
+ action === "cancel_early_reveal"
157
+ ) {
158
+ if (intro.source !== "BashSuggested" || intro.mode !== "Mystery") {
159
+ return { ok: false, error: "Early reveal is only for mystery intros" };
160
+ }
161
+ if (intro.state !== "Mutual") {
162
+ return { ok: false, error: "Early reveal opens once you are both interested" };
163
+ }
164
+ return unchanged(intro);
165
+ }
166
+
167
+ return { ok: false, error: "Unknown action" };
168
+ }
169
+
170
+ export function isParticipantAction(value: string): value is ParticipantAction {
171
+ return (PARTICIPANT_ACTIONS as readonly string[]).includes(value);
172
+ }