@bash-app/bash-common 30.203.0 → 30.210.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/src/index.ts CHANGED
@@ -4,16 +4,238 @@ export * from "./extendedSchemas.js";
4
4
  export * from "./bashFeedTypes.js";
5
5
  export * from "./membershipDefinitions.js";
6
6
 
7
- // Re-export commonly used Prisma types
7
+ // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
8
+ // Excludes enums already re-exported by definitions.ts and membershipDefinitions.ts:
9
+ // definitions.ts: YearsOfExperience, EntertainmentServiceType, MusicGenreType, ServiceTypes,
10
+ // CompetitionType, JudgingType, BracketType
11
+ // membershipDefinitions.ts: MembershipTier, ReferralTier, CreditTransactionType
12
+ export {
13
+ $Enums,
14
+ AISpecialistFormat,
15
+ AVTechnicianFormat,
16
+ AgeRange,
17
+ AmbashadorApplicationStatus,
18
+ AudioVisualSupportSubType,
19
+ AuditAction,
20
+ AuditCategory,
21
+ AuditSeverity,
22
+ BalloonArtFormat,
23
+ BalloonsFormat,
24
+ BartenderFormat,
25
+ BashAssociationType,
26
+ BashAvailabilityWindow,
27
+ BashEventDressTags,
28
+ BashEventSource,
29
+ BashEventType,
30
+ BashEventVibeTags,
31
+ BashFeedReportReason,
32
+ BashStatus,
33
+ BeverageCartFormat,
34
+ BirthdayCityResourceSuggestionStatus,
35
+ BirthdayCityResourceType,
36
+ BirthdayDealSuggestionStatus,
37
+ BirthdayFreebieCategory,
38
+ BirthdayFreebieExpirationType,
39
+ BirthdayFreebieSector,
40
+ BirthdayFreebieSource,
41
+ BirthdayOfferExpirationType,
42
+ BirthdayOfferQualityTier,
43
+ BirthdayOfferSourceType,
44
+ BookkeepersFormat,
45
+ CPAsFormat,
46
+ CateringFormat,
47
+ CelebrityAppearanceFormat,
48
+ CenterpiecesFormat,
49
+ ChairsFormat,
50
+ ClaimRequestStatus,
51
+ CleaningSubType,
52
+ ClubAdminRole,
53
+ ClubMemberStatus,
54
+ ConnectionStatus,
55
+ ConsentType,
56
+ ConsultantFormat,
57
+ CreditSourceType,
58
+ CreditTransactionStatus,
59
+ CrewHandFormat,
60
+ CustomSignageFormat,
61
+ DJLightingFormat,
62
+ DanceFloorFormat,
63
+ DayOfWeek,
64
+ DecorAndDesignSubType,
65
+ DecoratorFormat,
66
+ DeepCleaningFormat,
67
+ DessertBarFormat,
68
+ DiscountScope,
69
+ DiscountSourceType,
70
+ DroneShotsFormat,
71
+ Education,
72
+ EmceeFormat,
73
+ EntityType,
74
+ EventFormat,
75
+ EventManagerFormat,
76
+ EventPlannerFormat,
77
+ EventPlanningAndManagementSubType,
78
+ EventServiceType,
79
+ EventTemplateCategory,
80
+ ExhibitorBookingStatus,
81
+ FinancingSubType,
82
+ FloralFormat,
83
+ FoodAndBeverageSubType,
84
+ FoodCartStandFormat,
85
+ FoodTruckFormat,
86
+ FurnitureStylingFormat,
87
+ Gender,
88
+ GeneratorsFormat,
89
+ GoProWearerFormat,
90
+ GraphicDesignerFormat,
91
+ GreeterFormat,
92
+ GroupMemberStatus,
93
+ GroupVisibility,
94
+ GuestRegistrationFormat,
95
+ GuestShuttleFormat,
96
+ InfluencerFormat,
97
+ InteriorDesignerFormat,
98
+ InvestmentType,
99
+ InvestorFormat,
100
+ LawyersFormat,
101
+ LightCleaningFormat,
102
+ LightingFormat,
103
+ LightingTechFormat,
104
+ LimoFormat,
105
+ LinensFormat,
106
+ LongTermLoanFormat,
107
+ MediaEditorFormat,
108
+ MediaProductionAndCreativeSubType,
109
+ MediaType,
110
+ NotificationActionType,
111
+ NotificationPriority,
112
+ NotificationType,
113
+ Occupation,
114
+ OfferClaimStatus,
115
+ OfferDiscountType,
116
+ OrganizationType,
117
+ OtherSubType,
118
+ PASystemFormat,
119
+ ParkingManagementFormat,
120
+ ParticipantStatus,
121
+ PartyBusFormat,
122
+ PendingGiftStatus,
123
+ PhotoBoothFormat,
124
+ PhotographerFormat,
125
+ PredictionSource,
126
+ PredictionType,
127
+ PricingModel,
128
+ PricingType,
129
+ Privacy,
130
+ PrivateChefFormat,
131
+ PrizePaymentMethod,
132
+ PrizePaymentStatus,
133
+ PrizeType,
134
+ ProjectorSetupFormat,
135
+ PromoterFormat,
136
+ PromotionAndMarketingSubType,
137
+ RecurrenceEndsType,
138
+ RecurringFrequency,
139
+ RedemptionMethod,
140
+ RentalEquipmentSubType,
141
+ ReportStatus,
142
+ ReportingFormat,
143
+ RewardReportStatus,
144
+ ScoutTier,
145
+ SecurityFormat,
146
+ ServiceAddonCatalogKind,
147
+ ServiceAddonRequestStatus,
148
+ ServiceAddonStatus,
149
+ ServiceAnalyticsEvent,
150
+ ServiceBookingFeeType,
151
+ ServiceBookingRateOption,
152
+ ServiceBookingStatus,
153
+ ServiceBookingType,
154
+ ServiceCancellationPolicy,
155
+ ServiceCategoryRequestStatus,
156
+ ServiceCondition,
157
+ ServiceIncludeResponse,
158
+ ServiceIncludeStatus,
159
+ ServicePreference,
160
+ ServiceRatePricingType,
161
+ ServiceRateType,
162
+ ServiceStatus,
163
+ ServiceSubscriptionStatus,
164
+ Sex,
165
+ ShortTermLoanFormat,
166
+ SocialMediaPlatform,
167
+ SoundEngineerFormat,
168
+ SpecialOfferType,
169
+ SponsorBookingStatus,
170
+ SponsorTier,
171
+ SponsorType,
172
+ StaffingSubType,
173
+ StageTechFormat,
174
+ StagingFormat,
175
+ TableStylingFormat,
176
+ TablesFormat,
177
+ TaskStatus,
178
+ TentsFormat,
179
+ ThankYouCardsFormat,
180
+ TicketStatus,
181
+ TransferRequestStatus,
182
+ TransportationSubType,
183
+ UserIntent,
184
+ UserRole,
185
+ UserStatus,
186
+ UserSubscriptionStatus,
187
+ ValetFormat,
188
+ ValetServiceFormat,
189
+ VendedProductType,
190
+ VendorBidStatus,
191
+ VendorBookingStatus,
192
+ VendorServiceType,
193
+ VenuePricingPlan,
194
+ VideographerFormat,
195
+ VirtualAssistantFormat,
196
+ VisibilityPreference,
197
+ VolunteerServiceStatus,
198
+ VoteType,
199
+ VoucherStatus,
200
+ } from "@prisma/client";
201
+
202
+ // Re-export Prisma model types (type-only, no runtime value)
8
203
  export type {
9
- Service,
10
- Organization,
11
- User,
204
+ AmountOfGuests,
205
+ AssociatedBash,
206
+ AssociatedService,
12
207
  BashEvent,
208
+ BashEventPromoCode,
13
209
  BashFeedPost,
14
- BashAssociationType,
15
- Privacy,
16
- } from '@prisma/client';
210
+ BlockedUser,
211
+ CompetitionParticipant,
212
+ Contact,
213
+ CustomBashEventType,
214
+ DocumentID,
215
+ EventTask,
216
+ Exhibitor,
217
+ GoogleReview,
218
+ Invitation,
219
+ Link,
220
+ Media,
221
+ Organization,
222
+ Recurrence,
223
+ Service,
224
+ ServiceAddon,
225
+ ServiceBooking,
226
+ ServiceRate,
227
+ SocialMediaProfile,
228
+ Sponsor,
229
+ StripeAccount,
230
+ TargetAudience,
231
+ TaskInvitation,
232
+ Ticket,
233
+ TicketTier,
234
+ User,
235
+ UserPreferences,
236
+ UserSubscription,
237
+ Vendor,
238
+ } from "@prisma/client";
17
239
 
18
240
  export * from "./utils/addressUtils.js";
19
241
  export * from "./utils/apiUtils.js";
@@ -22,6 +244,18 @@ export * from "./utils/awsS3Utils.js";
22
244
  export * from "./utils/bashPointsPaymentUtils.js";
23
245
  export * from "./utils/contentFilterUtils.js";
24
246
  export * from "./utils/dateTimeUtils.js";
247
+ export {
248
+ getIanaTimezoneFromUsStateCode,
249
+ isValidIanaTimezone,
250
+ getIanaTimezoneFromCoordinates,
251
+ getIanaTimezoneFromLocationStringHints,
252
+ inferEventTimezoneIana,
253
+ resolveEventTimezoneIanaForDisplay,
254
+ } from "./utils/eventTimezoneIana.js";
255
+ export type {
256
+ InferEventTimezoneArgs,
257
+ ResolveEventTimezoneForDisplayArgs,
258
+ } from "./utils/eventTimezoneIana.js";
25
259
  export * from "./utils/displayDateUtils.js";
26
260
  export * from "./utils/discountEngine/bestPriceResolver.js";
27
261
  export * from "./utils/discountEngine/eligibilityValidator.js";
@@ -0,0 +1,92 @@
1
+ import {
2
+ getIanaTimezoneFromCoordinates,
3
+ inferEventTimezoneIana,
4
+ resolveEventTimezoneIanaForDisplay,
5
+ } from "../eventTimezoneIana.js";
6
+
7
+ describe("eventTimezoneIana", () => {
8
+ describe("getIanaTimezoneFromCoordinates", () => {
9
+ it("returns Pacific for San Francisco", () => {
10
+ expect(getIanaTimezoneFromCoordinates(37.7749, -122.4194)).toBe(
11
+ "America/Los_Angeles"
12
+ );
13
+ });
14
+
15
+ it("returns Mountain for Salt Lake City area", () => {
16
+ expect(getIanaTimezoneFromCoordinates(39.7392, -111.0)).toBe(
17
+ "America/Denver"
18
+ );
19
+ });
20
+
21
+ it("returns Central for Chicago", () => {
22
+ expect(getIanaTimezoneFromCoordinates(41.8781, -87.6298)).toBe(
23
+ "America/Chicago"
24
+ );
25
+ });
26
+
27
+ it("returns Eastern for New York", () => {
28
+ expect(getIanaTimezoneFromCoordinates(40.7128, -74.006)).toBe(
29
+ "America/New_York"
30
+ );
31
+ });
32
+
33
+ it("returns Phoenix for Tempe AZ", () => {
34
+ expect(getIanaTimezoneFromCoordinates(33.4484, -111.94)).toBe(
35
+ "America/Phoenix"
36
+ );
37
+ });
38
+
39
+ it("returns null when coordinates are missing", () => {
40
+ expect(getIanaTimezoneFromCoordinates(null, -122)).toBeNull();
41
+ expect(getIanaTimezoneFromCoordinates(40, undefined)).toBeNull();
42
+ });
43
+
44
+ it("returns null for Null Island (0, 0)", () => {
45
+ expect(getIanaTimezoneFromCoordinates(0, 0)).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe("inferEventTimezoneIana", () => {
50
+ it("uses US state when coordinates are missing", () => {
51
+ expect(
52
+ inferEventTimezoneIana({
53
+ state: "UT",
54
+ country: "USA",
55
+ })
56
+ ).toBe("America/Denver");
57
+ expect(
58
+ inferEventTimezoneIana({
59
+ state: "AZ",
60
+ country: "USA",
61
+ })
62
+ ).toBe("America/Phoenix");
63
+ });
64
+
65
+ it("falls back to UTC when nothing matches", () => {
66
+ expect(inferEventTimezoneIana({})).toBe("UTC");
67
+ });
68
+ });
69
+
70
+ describe("resolveEventTimezoneIanaForDisplay", () => {
71
+ it("prefers valid stored timezone", () => {
72
+ expect(
73
+ resolveEventTimezoneIanaForDisplay({
74
+ timezone: "America/Los_Angeles",
75
+ state: "UT",
76
+ country: "USA",
77
+ })
78
+ ).toBe("America/Los_Angeles");
79
+ });
80
+
81
+ it("infers from city/state when timezone is missing", () => {
82
+ expect(
83
+ resolveEventTimezoneIanaForDisplay({
84
+ timezone: null,
85
+ city: "Ogden",
86
+ state: "UT",
87
+ country: "United States",
88
+ })
89
+ ).toBe("America/Denver");
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,331 @@
1
+ import { DateTime } from "luxon";
2
+
3
+ /** Primary IANA zone for each US state + DC (contiguous conventions; AZ/HI/AK use canonical zones). */
4
+ const US_STATE_CODE_TO_IANA: Record<string, string> = {
5
+ AL: "America/Chicago",
6
+ AK: "America/Anchorage",
7
+ AZ: "America/Phoenix",
8
+ AR: "America/Chicago",
9
+ CA: "America/Los_Angeles",
10
+ CO: "America/Denver",
11
+ CT: "America/New_York",
12
+ DE: "America/New_York",
13
+ DC: "America/New_York",
14
+ FL: "America/New_York",
15
+ GA: "America/New_York",
16
+ HI: "Pacific/Honolulu",
17
+ ID: "America/Boise",
18
+ IL: "America/Chicago",
19
+ IN: "America/Indiana/Indianapolis",
20
+ IA: "America/Chicago",
21
+ KS: "America/Chicago",
22
+ KY: "America/New_York",
23
+ LA: "America/Chicago",
24
+ ME: "America/New_York",
25
+ MD: "America/New_York",
26
+ MA: "America/New_York",
27
+ MI: "America/Detroit",
28
+ MN: "America/Chicago",
29
+ MS: "America/Chicago",
30
+ MO: "America/Chicago",
31
+ MT: "America/Denver",
32
+ NE: "America/Chicago",
33
+ NV: "America/Los_Angeles",
34
+ NH: "America/New_York",
35
+ NJ: "America/New_York",
36
+ NM: "America/Denver",
37
+ NY: "America/New_York",
38
+ NC: "America/New_York",
39
+ ND: "America/Chicago",
40
+ OH: "America/New_York",
41
+ OK: "America/Chicago",
42
+ OR: "America/Los_Angeles",
43
+ PA: "America/New_York",
44
+ RI: "America/New_York",
45
+ SC: "America/New_York",
46
+ SD: "America/Chicago",
47
+ TN: "America/Chicago",
48
+ TX: "America/Chicago",
49
+ UT: "America/Denver",
50
+ VT: "America/New_York",
51
+ VA: "America/New_York",
52
+ WA: "America/Los_Angeles",
53
+ WV: "America/New_York",
54
+ WI: "America/Chicago",
55
+ WY: "America/Denver",
56
+ };
57
+
58
+ function isUsCountry(country: string | null | undefined): boolean {
59
+ if (!country?.trim()) return true;
60
+ const c = country.trim().toLowerCase();
61
+ return (
62
+ c === "us" ||
63
+ c === "usa" ||
64
+ c === "united states" ||
65
+ c === "united states of america"
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Maps a US state code (e.g. "UT") to IANA when country is US/unknown.
71
+ * Returns null if state is missing or not a 2-letter US code.
72
+ */
73
+ export function getIanaTimezoneFromUsStateCode(
74
+ state: string | null | undefined,
75
+ country: string | null | undefined
76
+ ): string | null {
77
+ if (!state?.trim()) return null;
78
+ const code = state.trim().toUpperCase();
79
+ if (code.length !== 2) return null;
80
+ if (!isUsCountry(country)) return null;
81
+ return US_STATE_CODE_TO_IANA[code] ?? null;
82
+ }
83
+
84
+ export function isValidIanaTimezone(timezone: string | null | undefined): boolean {
85
+ const t = timezone?.trim();
86
+ if (!t) return false;
87
+ return DateTime.now().setZone(t).isValid;
88
+ }
89
+
90
+ /**
91
+ * Longitude/latitude → IANA for common regions. Returns null when coords are missing
92
+ * or no region matches (caller should fall back to address hints or UTC).
93
+ */
94
+ export function getIanaTimezoneFromCoordinates(
95
+ latitude: number | null | undefined,
96
+ longitude: number | null | undefined
97
+ ): string | null {
98
+ if (
99
+ latitude === null ||
100
+ latitude === undefined ||
101
+ longitude === null ||
102
+ longitude === undefined
103
+ ) {
104
+ return null;
105
+ }
106
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
107
+ return null;
108
+ }
109
+
110
+ // Null Island / indeterminate — do not guess a zone from (0, 0).
111
+ if (latitude === 0 && longitude === 0) {
112
+ return null;
113
+ }
114
+
115
+ // Hawaii first
116
+ if (
117
+ longitude >= -160 &&
118
+ longitude <= -154 &&
119
+ latitude >= 18.9 &&
120
+ latitude <= 22.5
121
+ ) {
122
+ return "Pacific/Honolulu";
123
+ }
124
+
125
+ // Alaska / Aleutian
126
+ if (longitude >= -180 && longitude <= -140) {
127
+ if (longitude <= -169.5) {
128
+ return "America/Adak";
129
+ }
130
+ return "America/Anchorage";
131
+ }
132
+
133
+ // Continental US — fixed west-to-east bands (avoids misclassifying Mountain vs Central)
134
+ if (latitude >= 18.9 && latitude <= 71.5388) {
135
+ if (longitude <= -114) {
136
+ return "America/Los_Angeles";
137
+ }
138
+ if (longitude > -114 && longitude <= -102) {
139
+ if (
140
+ latitude >= 31.3 &&
141
+ latitude <= 37.0 &&
142
+ longitude >= -114.8 &&
143
+ longitude <= -109.0
144
+ ) {
145
+ return "America/Phoenix";
146
+ }
147
+ return "America/Denver";
148
+ }
149
+ if (longitude > -102 && longitude <= -87) {
150
+ return "America/Chicago";
151
+ }
152
+ if (longitude > -87 && longitude <= -67) {
153
+ return "America/New_York";
154
+ }
155
+ }
156
+
157
+ // Europe / Africa (coarse)
158
+ if (longitude >= -10 && longitude <= 40) {
159
+ if (longitude >= -1 && longitude <= 1) {
160
+ return "Europe/London";
161
+ }
162
+ if (longitude >= 1 && longitude <= 3) {
163
+ return "Europe/Paris";
164
+ }
165
+ if (longitude >= 13 && longitude <= 15) {
166
+ return "Europe/Berlin";
167
+ }
168
+ if (longitude >= 20 && longitude <= 30) {
169
+ return "Europe/Helsinki";
170
+ }
171
+ } else if (longitude >= 100 && longitude <= 180) {
172
+ if (longitude >= 139 && longitude <= 141) {
173
+ return "Asia/Tokyo";
174
+ }
175
+ if (longitude >= 116 && longitude <= 118) {
176
+ return "Asia/Shanghai";
177
+ }
178
+ if (longitude >= 150 && longitude <= 155) {
179
+ return "Australia/Sydney";
180
+ }
181
+ if (longitude >= 115 && longitude <= 120) {
182
+ return "Australia/Perth";
183
+ }
184
+ } else if (longitude >= -80 && longitude <= -30) {
185
+ if (latitude !== null && latitude !== undefined && latitude >= 45) {
186
+ if (longitude >= -80 && longitude <= -74) {
187
+ return "America/Toronto";
188
+ }
189
+ if (longitude >= -110 && longitude <= -100) {
190
+ return "America/Winnipeg";
191
+ }
192
+ if (longitude >= -125 && longitude <= -115) {
193
+ return "America/Vancouver";
194
+ }
195
+ }
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * Substring hints for free-text location (pipe-delimited or legacy). Avoids single-letter
203
+ * state codes like "ut" that false-positive inside unrelated words.
204
+ */
205
+ export function getIanaTimezoneFromLocationStringHints(
206
+ location: string | null | undefined
207
+ ): string | null {
208
+ if (!location?.trim()) return null;
209
+ const locationLower = location.toLowerCase();
210
+
211
+ if (
212
+ locationLower.includes("new jersey") ||
213
+ /\bnj\b/.test(locationLower) ||
214
+ locationLower.includes("new york") ||
215
+ /\bny\b/.test(locationLower) ||
216
+ locationLower.includes("massachusetts") ||
217
+ /\bma\b/.test(locationLower) ||
218
+ locationLower.includes("florida") ||
219
+ /\bfl\b/.test(locationLower) ||
220
+ locationLower.includes("georgia") ||
221
+ /\bga\b/.test(locationLower) ||
222
+ locationLower.includes("keansburg")
223
+ ) {
224
+ return "America/New_York";
225
+ }
226
+
227
+ if (
228
+ locationLower.includes("california") ||
229
+ locationLower.includes(", ca") ||
230
+ locationLower.endsWith(" ca") ||
231
+ locationLower.includes("washington") ||
232
+ locationLower.includes(", wa") ||
233
+ locationLower.endsWith(" wa") ||
234
+ locationLower.includes("oregon") ||
235
+ locationLower.includes("nevada") ||
236
+ /\bnv\b/.test(locationLower)
237
+ ) {
238
+ return "America/Los_Angeles";
239
+ }
240
+
241
+ if (
242
+ locationLower.includes("texas") ||
243
+ /\btx\b/.test(locationLower) ||
244
+ locationLower.includes("illinois") ||
245
+ /\bil\b/.test(locationLower) ||
246
+ locationLower.includes("chicago")
247
+ ) {
248
+ return "America/Chicago";
249
+ }
250
+
251
+ if (locationLower.includes("arizona") || /\baz\b/.test(locationLower)) {
252
+ return "America/Phoenix";
253
+ }
254
+
255
+ if (
256
+ locationLower.includes("colorado") ||
257
+ locationLower.includes(", co") ||
258
+ locationLower.endsWith(" co") ||
259
+ locationLower.includes("utah") ||
260
+ locationLower.includes(", ut") ||
261
+ locationLower.includes(" ut,") ||
262
+ locationLower.endsWith(" ut")
263
+ ) {
264
+ return "America/Denver";
265
+ }
266
+
267
+ if (locationLower.includes("london") || locationLower.includes("uk")) {
268
+ return "Europe/London";
269
+ }
270
+ if (locationLower.includes("paris") || locationLower.includes("france")) {
271
+ return "Europe/Paris";
272
+ }
273
+ if (locationLower.includes("tokyo") || locationLower.includes("japan")) {
274
+ return "Asia/Tokyo";
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ export type InferEventTimezoneArgs = {
281
+ location?: string | null;
282
+ latitude?: number | null;
283
+ longitude?: number | null;
284
+ city?: string | null;
285
+ state?: string | null;
286
+ country?: string | null;
287
+ };
288
+
289
+ /**
290
+ * Infer IANA from coordinates, then US state, then location/city string hints.
291
+ * Never uses the viewer's device timezone. Falls back to UTC.
292
+ */
293
+ export function inferEventTimezoneIana(args: InferEventTimezoneArgs): string {
294
+ const fromCoords = getIanaTimezoneFromCoordinates(
295
+ args.latitude,
296
+ args.longitude
297
+ );
298
+ if (fromCoords) return fromCoords;
299
+
300
+ const fromState = getIanaTimezoneFromUsStateCode(args.state, args.country);
301
+ if (fromState) return fromState;
302
+
303
+ const fromLoc = getIanaTimezoneFromLocationStringHints(args.location);
304
+ if (fromLoc) return fromLoc;
305
+
306
+ const cityLine = [args.city, args.state, args.country]
307
+ .filter((p): p is string => !!p?.trim())
308
+ .join(", ");
309
+ const fromCityLine = getIanaTimezoneFromLocationStringHints(cityLine);
310
+ if (fromCityLine) return fromCityLine;
311
+
312
+ return "UTC";
313
+ }
314
+
315
+ export type ResolveEventTimezoneForDisplayArgs = InferEventTimezoneArgs & {
316
+ timezone?: string | null;
317
+ };
318
+
319
+ /**
320
+ * For public surfaces (e.g. BashCard): prefer a valid stored IANA, then infer from geography.
321
+ * Never uses the viewer's device timezone.
322
+ */
323
+ export function resolveEventTimezoneIanaForDisplay(
324
+ args: ResolveEventTimezoneForDisplayArgs
325
+ ): string {
326
+ const stored = args.timezone?.trim();
327
+ if (stored && isValidIanaTimezone(stored)) {
328
+ return stored;
329
+ }
330
+ return inferEventTimezoneIana(args);
331
+ }