@bash-app/bash-common 30.347.0 → 30.355.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__/eventTimezoneIana.test.d.ts +2 -0
- package/dist/__tests__/eventTimezoneIana.test.d.ts.map +1 -0
- package/dist/__tests__/eventTimezoneIana.test.js +282 -0
- package/dist/__tests__/eventTimezoneIana.test.js.map +1 -0
- package/dist/extendedSchemas.d.ts +177 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +3 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/moodTrackUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/moodTrackUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/moodTrackUtils.test.js +25 -0
- package/dist/utils/__tests__/moodTrackUtils.test.js.map +1 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts +4 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -1
- package/dist/utils/eventTimezoneIana.d.ts.map +1 -1
- package/dist/utils/eventTimezoneIana.js +15 -2
- package/dist/utils/eventTimezoneIana.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/luxonUtils.d.ts +7 -7
- package/dist/utils/luxonUtils.d.ts.map +1 -1
- package/dist/utils/moodTrackUtils.d.ts +23 -0
- package/dist/utils/moodTrackUtils.d.ts.map +1 -0
- package/dist/utils/moodTrackUtils.js +171 -0
- package/dist/utils/moodTrackUtils.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +26 -0
- package/src/__tests__/eventTimezoneIana.test.ts +387 -0
- package/src/extendedSchemas.ts +3 -0
- package/src/index.ts +1 -0
- package/src/utils/__tests__/moodTrackUtils.test.ts +46 -0
- package/src/utils/eventTimezoneIana.ts +14 -3
- package/src/utils/index.ts +1 -0
- package/src/utils/moodTrackUtils.ts +190 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getIanaTimezoneFromCoordinates,
|
|
3
|
+
getIanaTimezoneFromUsStateCode,
|
|
4
|
+
inferEventTimezoneIana,
|
|
5
|
+
isValidIanaTimezone,
|
|
6
|
+
resolveEventTimezoneIanaForDisplay,
|
|
7
|
+
} from "../utils/eventTimezoneIana";
|
|
8
|
+
|
|
9
|
+
describe("eventTimezoneIana", () => {
|
|
10
|
+
describe("isValidIanaTimezone", () => {
|
|
11
|
+
describe("valid IANA timezones", () => {
|
|
12
|
+
it("accepts US mainland timezones", () => {
|
|
13
|
+
expect(isValidIanaTimezone("America/New_York")).toBe(true);
|
|
14
|
+
expect(isValidIanaTimezone("America/Chicago")).toBe(true);
|
|
15
|
+
expect(isValidIanaTimezone("America/Denver")).toBe(true);
|
|
16
|
+
expect(isValidIanaTimezone("America/Los_Angeles")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts US special zones", () => {
|
|
20
|
+
expect(isValidIanaTimezone("America/Phoenix")).toBe(true); // Arizona (no DST)
|
|
21
|
+
expect(isValidIanaTimezone("America/Anchorage")).toBe(true); // Alaska
|
|
22
|
+
expect(isValidIanaTimezone("Pacific/Honolulu")).toBe(true); // Hawaii
|
|
23
|
+
expect(isValidIanaTimezone("America/Indiana/Indianapolis")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts European timezones", () => {
|
|
27
|
+
expect(isValidIanaTimezone("Europe/London")).toBe(true);
|
|
28
|
+
expect(isValidIanaTimezone("Europe/Paris")).toBe(true);
|
|
29
|
+
expect(isValidIanaTimezone("Europe/Berlin")).toBe(true);
|
|
30
|
+
expect(isValidIanaTimezone("Europe/Rome")).toBe(true);
|
|
31
|
+
expect(isValidIanaTimezone("Europe/Madrid")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts Asian timezones", () => {
|
|
35
|
+
expect(isValidIanaTimezone("Asia/Tokyo")).toBe(true);
|
|
36
|
+
expect(isValidIanaTimezone("Asia/Shanghai")).toBe(true);
|
|
37
|
+
expect(isValidIanaTimezone("Asia/Singapore")).toBe(true);
|
|
38
|
+
expect(isValidIanaTimezone("Asia/Dubai")).toBe(true);
|
|
39
|
+
expect(isValidIanaTimezone("Asia/Kolkata")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts other major timezones", () => {
|
|
43
|
+
expect(isValidIanaTimezone("Australia/Sydney")).toBe(true);
|
|
44
|
+
expect(isValidIanaTimezone("America/Sao_Paulo")).toBe(true);
|
|
45
|
+
expect(isValidIanaTimezone("Africa/Johannesburg")).toBe(true);
|
|
46
|
+
expect(isValidIanaTimezone("UTC")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts case variations (Intl is case-insensitive)", () => {
|
|
50
|
+
expect(isValidIanaTimezone("America/New_York")).toBe(true);
|
|
51
|
+
// Intl.DateTimeFormat is case-insensitive (unlike Luxon)
|
|
52
|
+
expect(isValidIanaTimezone("america/new_york")).toBe(true);
|
|
53
|
+
expect(isValidIanaTimezone("AMERICA/NEW_YORK")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("invalid timezones", () => {
|
|
58
|
+
it("rejects timezone abbreviations (ambiguous, not IANA standard)", () => {
|
|
59
|
+
// These are explicitly rejected because they're ambiguous:
|
|
60
|
+
// EST could be US Eastern or Australian Eastern, etc.
|
|
61
|
+
expect(isValidIanaTimezone("EST")).toBe(false);
|
|
62
|
+
expect(isValidIanaTimezone("PST")).toBe(false);
|
|
63
|
+
expect(isValidIanaTimezone("CST")).toBe(false);
|
|
64
|
+
expect(isValidIanaTimezone("MST")).toBe(false);
|
|
65
|
+
expect(isValidIanaTimezone("EDT")).toBe(false);
|
|
66
|
+
expect(isValidIanaTimezone("PDT")).toBe(false);
|
|
67
|
+
expect(isValidIanaTimezone("AEST")).toBe(false);
|
|
68
|
+
expect(isValidIanaTimezone("JST")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects made-up timezone names", () => {
|
|
72
|
+
expect(isValidIanaTimezone("Invalid/Zone")).toBe(false);
|
|
73
|
+
expect(isValidIanaTimezone("America/FakeCity")).toBe(false);
|
|
74
|
+
expect(isValidIanaTimezone("Not_A_Timezone")).toBe(false);
|
|
75
|
+
expect(isValidIanaTimezone("Random/Gibberish")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects empty or whitespace strings", () => {
|
|
79
|
+
expect(isValidIanaTimezone("")).toBe(false);
|
|
80
|
+
expect(isValidIanaTimezone(" ")).toBe(false);
|
|
81
|
+
expect(isValidIanaTimezone("\t")).toBe(false);
|
|
82
|
+
expect(isValidIanaTimezone("\n")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects null and undefined", () => {
|
|
86
|
+
expect(isValidIanaTimezone(null)).toBe(false);
|
|
87
|
+
expect(isValidIanaTimezone(undefined)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects numeric GMT offsets", () => {
|
|
91
|
+
expect(isValidIanaTimezone("GMT+5")).toBe(false);
|
|
92
|
+
expect(isValidIanaTimezone("UTC-8")).toBe(false);
|
|
93
|
+
expect(isValidIanaTimezone("+05:00")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("whitespace handling", () => {
|
|
98
|
+
it("trims leading/trailing whitespace", () => {
|
|
99
|
+
expect(isValidIanaTimezone(" America/New_York ")).toBe(true);
|
|
100
|
+
expect(isValidIanaTimezone("\tEurope/London\n")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects zones with internal spaces", () => {
|
|
104
|
+
expect(isValidIanaTimezone("America/ New_York")).toBe(false);
|
|
105
|
+
expect(isValidIanaTimezone("America /New_York")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("getIanaTimezoneFromUsStateCode", () => {
|
|
111
|
+
it("maps standard US state codes", () => {
|
|
112
|
+
expect(getIanaTimezoneFromUsStateCode("CA", "US")).toBe(
|
|
113
|
+
"America/Los_Angeles"
|
|
114
|
+
);
|
|
115
|
+
expect(getIanaTimezoneFromUsStateCode("NY", "US")).toBe(
|
|
116
|
+
"America/New_York"
|
|
117
|
+
);
|
|
118
|
+
expect(getIanaTimezoneFromUsStateCode("TX", "US")).toBe(
|
|
119
|
+
"America/Chicago"
|
|
120
|
+
);
|
|
121
|
+
expect(getIanaTimezoneFromUsStateCode("UT", "US")).toBe("America/Denver");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("maps special zones", () => {
|
|
125
|
+
expect(getIanaTimezoneFromUsStateCode("AZ", "US")).toBe(
|
|
126
|
+
"America/Phoenix"
|
|
127
|
+
);
|
|
128
|
+
expect(getIanaTimezoneFromUsStateCode("AK", "US")).toBe(
|
|
129
|
+
"America/Anchorage"
|
|
130
|
+
);
|
|
131
|
+
expect(getIanaTimezoneFromUsStateCode("HI", "US")).toBe(
|
|
132
|
+
"Pacific/Honolulu"
|
|
133
|
+
);
|
|
134
|
+
expect(getIanaTimezoneFromUsStateCode("IN", "US")).toBe(
|
|
135
|
+
"America/Indiana/Indianapolis"
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles DC", () => {
|
|
140
|
+
expect(getIanaTimezoneFromUsStateCode("DC", "US")).toBe(
|
|
141
|
+
"America/New_York"
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("is case-insensitive for state codes", () => {
|
|
146
|
+
expect(getIanaTimezoneFromUsStateCode("ca", "US")).toBe(
|
|
147
|
+
"America/Los_Angeles"
|
|
148
|
+
);
|
|
149
|
+
expect(getIanaTimezoneFromUsStateCode("ny", "US")).toBe(
|
|
150
|
+
"America/New_York"
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("assumes US when country is null/undefined", () => {
|
|
155
|
+
expect(getIanaTimezoneFromUsStateCode("CA", null)).toBe(
|
|
156
|
+
"America/Los_Angeles"
|
|
157
|
+
);
|
|
158
|
+
expect(getIanaTimezoneFromUsStateCode("NY", undefined)).toBe(
|
|
159
|
+
"America/New_York"
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("accepts USA/United States variations", () => {
|
|
164
|
+
expect(getIanaTimezoneFromUsStateCode("CA", "USA")).toBe(
|
|
165
|
+
"America/Los_Angeles"
|
|
166
|
+
);
|
|
167
|
+
expect(getIanaTimezoneFromUsStateCode("NY", "United States")).toBe(
|
|
168
|
+
"America/New_York"
|
|
169
|
+
);
|
|
170
|
+
expect(
|
|
171
|
+
getIanaTimezoneFromUsStateCode("TX", "United States of America")
|
|
172
|
+
).toBe("America/Chicago");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns null for non-US countries", () => {
|
|
176
|
+
expect(getIanaTimezoneFromUsStateCode("CA", "Canada")).toBeNull();
|
|
177
|
+
expect(getIanaTimezoneFromUsStateCode("NY", "Mexico")).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns null for invalid state codes", () => {
|
|
181
|
+
expect(getIanaTimezoneFromUsStateCode("XX", "US")).toBeNull();
|
|
182
|
+
expect(getIanaTimezoneFromUsStateCode("ZZZ", "US")).toBeNull();
|
|
183
|
+
expect(getIanaTimezoneFromUsStateCode("California", "US")).toBeNull(); // Full name, not code
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns null for null/undefined/empty state", () => {
|
|
187
|
+
expect(getIanaTimezoneFromUsStateCode(null, "US")).toBeNull();
|
|
188
|
+
expect(getIanaTimezoneFromUsStateCode(undefined, "US")).toBeNull();
|
|
189
|
+
expect(getIanaTimezoneFromUsStateCode("", "US")).toBeNull();
|
|
190
|
+
expect(getIanaTimezoneFromUsStateCode(" ", "US")).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("getIanaTimezoneFromCoordinates", () => {
|
|
195
|
+
describe("US regions", () => {
|
|
196
|
+
it("resolves Eastern Time", () => {
|
|
197
|
+
// New York City
|
|
198
|
+
expect(getIanaTimezoneFromCoordinates(40.7128, -74.006)).toBe(
|
|
199
|
+
"America/New_York"
|
|
200
|
+
);
|
|
201
|
+
// Miami
|
|
202
|
+
expect(getIanaTimezoneFromCoordinates(25.7617, -80.1918)).toBe(
|
|
203
|
+
"America/New_York"
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("resolves Central Time", () => {
|
|
208
|
+
// Chicago
|
|
209
|
+
expect(getIanaTimezoneFromCoordinates(41.8781, -87.6298)).toBe(
|
|
210
|
+
"America/Chicago"
|
|
211
|
+
);
|
|
212
|
+
// Houston
|
|
213
|
+
expect(getIanaTimezoneFromCoordinates(29.7604, -95.3698)).toBe(
|
|
214
|
+
"America/Chicago"
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("resolves Mountain Time", () => {
|
|
219
|
+
// Denver
|
|
220
|
+
expect(getIanaTimezoneFromCoordinates(39.7392, -104.9903)).toBe(
|
|
221
|
+
"America/Denver"
|
|
222
|
+
);
|
|
223
|
+
// Salt Lake City
|
|
224
|
+
expect(getIanaTimezoneFromCoordinates(40.7608, -111.891)).toBe(
|
|
225
|
+
"America/Denver"
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("resolves Pacific Time", () => {
|
|
230
|
+
// Los Angeles
|
|
231
|
+
expect(getIanaTimezoneFromCoordinates(34.0522, -118.2437)).toBe(
|
|
232
|
+
"America/Los_Angeles"
|
|
233
|
+
);
|
|
234
|
+
// Seattle
|
|
235
|
+
expect(getIanaTimezoneFromCoordinates(47.6062, -122.3321)).toBe(
|
|
236
|
+
"America/Los_Angeles"
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("resolves special zones", () => {
|
|
241
|
+
// Phoenix (Arizona - no DST)
|
|
242
|
+
expect(getIanaTimezoneFromCoordinates(33.4484, -112.074)).toBe(
|
|
243
|
+
"America/Phoenix"
|
|
244
|
+
);
|
|
245
|
+
// Anchorage (Alaska)
|
|
246
|
+
expect(getIanaTimezoneFromCoordinates(61.2181, -149.9003)).toBe(
|
|
247
|
+
"America/Anchorage"
|
|
248
|
+
);
|
|
249
|
+
// Honolulu (Hawaii)
|
|
250
|
+
expect(getIanaTimezoneFromCoordinates(21.3099, -157.8581)).toBe(
|
|
251
|
+
"Pacific/Honolulu"
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("international regions", () => {
|
|
257
|
+
it("resolves European zones", () => {
|
|
258
|
+
// London
|
|
259
|
+
expect(getIanaTimezoneFromCoordinates(51.5074, -0.1278)).toBe(
|
|
260
|
+
"Europe/London"
|
|
261
|
+
);
|
|
262
|
+
// Paris
|
|
263
|
+
expect(getIanaTimezoneFromCoordinates(48.8566, 2.3522)).toBe(
|
|
264
|
+
"Europe/Paris"
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("resolves Asian zones", () => {
|
|
269
|
+
// Tokyo
|
|
270
|
+
expect(getIanaTimezoneFromCoordinates(35.6762, 139.6503)).toBe(
|
|
271
|
+
"Asia/Tokyo"
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns null for missing coordinates", () => {
|
|
277
|
+
expect(getIanaTimezoneFromCoordinates(null, -74.006)).toBeNull();
|
|
278
|
+
expect(getIanaTimezoneFromCoordinates(40.7128, null)).toBeNull();
|
|
279
|
+
expect(getIanaTimezoneFromCoordinates(null, null)).toBeNull();
|
|
280
|
+
expect(getIanaTimezoneFromCoordinates(undefined, undefined)).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns null for coordinates with no regional match", () => {
|
|
284
|
+
// Middle of the South Atlantic Ocean - truly remote
|
|
285
|
+
expect(getIanaTimezoneFromCoordinates(-30, -30)).toBeNull();
|
|
286
|
+
// Far southern ocean between Africa and Antarctica
|
|
287
|
+
expect(getIanaTimezoneFromCoordinates(-60, 40)).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("inferEventTimezoneIana", () => {
|
|
292
|
+
it("prefers coordinates over state code", () => {
|
|
293
|
+
// LA coords but NY state - coords should win
|
|
294
|
+
const result = inferEventTimezoneIana({
|
|
295
|
+
latitude: 34.0522,
|
|
296
|
+
longitude: -118.2437,
|
|
297
|
+
state: "NY",
|
|
298
|
+
country: "US",
|
|
299
|
+
});
|
|
300
|
+
expect(result).toBe("America/Los_Angeles");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("falls back to state code when coordinates missing", () => {
|
|
304
|
+
const result = inferEventTimezoneIana({
|
|
305
|
+
state: "TX",
|
|
306
|
+
country: "US",
|
|
307
|
+
});
|
|
308
|
+
expect(result).toBe("America/Chicago");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("falls back to location string hints", () => {
|
|
312
|
+
const result = inferEventTimezoneIana({
|
|
313
|
+
location: "Denver, CO",
|
|
314
|
+
});
|
|
315
|
+
expect(result).toBe("America/Denver");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("returns UTC when nothing matches", () => {
|
|
319
|
+
const result = inferEventTimezoneIana({
|
|
320
|
+
city: "Unknown",
|
|
321
|
+
state: "XX",
|
|
322
|
+
country: "Unknown",
|
|
323
|
+
});
|
|
324
|
+
expect(result).toBe("UTC");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("handles empty args", () => {
|
|
328
|
+
const result = inferEventTimezoneIana({});
|
|
329
|
+
expect(result).toBe("UTC");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("resolveEventTimezoneIanaForDisplay", () => {
|
|
334
|
+
it("prefers inferred timezone over stored", () => {
|
|
335
|
+
// NYC coords with wrong stored timezone
|
|
336
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
337
|
+
latitude: 40.7128,
|
|
338
|
+
longitude: -74.006,
|
|
339
|
+
timezone: "America/Los_Angeles", // wrong
|
|
340
|
+
});
|
|
341
|
+
expect(result).toBe("America/New_York");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("uses stored timezone when inference returns UTC", () => {
|
|
345
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
346
|
+
timezone: "Europe/London",
|
|
347
|
+
city: "Unknown",
|
|
348
|
+
state: "XX",
|
|
349
|
+
});
|
|
350
|
+
expect(result).toBe("Europe/London");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("validates stored timezone before using it", () => {
|
|
354
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
355
|
+
timezone: "Invalid/Zone",
|
|
356
|
+
});
|
|
357
|
+
expect(result).toBe("UTC");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("rejects timezone abbreviations in stored field", () => {
|
|
361
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
362
|
+
timezone: "EST",
|
|
363
|
+
});
|
|
364
|
+
expect(result).toBe("UTC");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("returns UTC when everything fails", () => {
|
|
368
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
369
|
+
timezone: null,
|
|
370
|
+
city: "Unknown",
|
|
371
|
+
});
|
|
372
|
+
expect(result).toBe("UTC");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("handles BashCard typical inputs", () => {
|
|
376
|
+
// Typical event with coords and timezone
|
|
377
|
+
const result = resolveEventTimezoneIanaForDisplay({
|
|
378
|
+
latitude: 40.7608,
|
|
379
|
+
longitude: -111.891,
|
|
380
|
+
timezone: "America/Denver",
|
|
381
|
+
city: "Salt Lake City",
|
|
382
|
+
state: "UT",
|
|
383
|
+
});
|
|
384
|
+
expect(result).toBe("America/Denver");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -208,6 +208,9 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
|
|
|
208
208
|
/** BashPoints loyalty (API may omit when viewing someone else’s profile). */
|
|
209
209
|
bashPointsBalance: true,
|
|
210
210
|
bashPointsEarnedLifetime: true,
|
|
211
|
+
/** Home hero “Stuff the horn” best clear (ms; lower is better). */
|
|
212
|
+
hornClearBestMs: true,
|
|
213
|
+
hornClearBestAt: true,
|
|
211
214
|
/** Host reputation fields */
|
|
212
215
|
hostRating: true,
|
|
213
216
|
totalRatings: true,
|
package/src/index.ts
CHANGED
|
@@ -401,6 +401,7 @@ export * from "./utils/sortUtils.js";
|
|
|
401
401
|
export * from "./utils/stringUtils.js";
|
|
402
402
|
export * from "./utils/ticketListUtils.js";
|
|
403
403
|
export * from "./utils/urlUtils.js";
|
|
404
|
+
export * from "./utils/moodTrackUtils.js";
|
|
404
405
|
export * from "./utils/userPromoCodeUtils.js";
|
|
405
406
|
export * from "./utils/userSubscriptionUtils.js";
|
|
406
407
|
export * from "./utils/blog/blogDbUtils.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMoodTrackUrlAllowed,
|
|
3
|
+
normalizeMoodTrackUrl,
|
|
4
|
+
parseMoodTrackUrl,
|
|
5
|
+
} from "../moodTrackUtils";
|
|
6
|
+
|
|
7
|
+
describe("moodTrackUtils", () => {
|
|
8
|
+
it("parses YouTube Music watch URLs into youtube embeds", () => {
|
|
9
|
+
const parsed = parseMoodTrackUrl(
|
|
10
|
+
"https://music.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
11
|
+
);
|
|
12
|
+
expect(parsed?.provider).toBe("youtubeMusic");
|
|
13
|
+
expect(parsed?.embedUrl).toContain("youtube.com/embed/dQw4w9WgXcQ");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses Spotify track URLs", () => {
|
|
17
|
+
const parsed = parseMoodTrackUrl(
|
|
18
|
+
"https://open.spotify.com/track/11dFghVXANMlKmJXsNCbNl"
|
|
19
|
+
);
|
|
20
|
+
expect(parsed?.provider).toBe("spotify");
|
|
21
|
+
expect(parsed?.embedUrl).toBe(
|
|
22
|
+
"https://open.spotify.com/embed/track/11dFghVXANMlKmJXsNCbNl"
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses Apple Music and SoundCloud", () => {
|
|
27
|
+
expect(
|
|
28
|
+
parseMoodTrackUrl(
|
|
29
|
+
"https://music.apple.com/us/album/example/123?i=456"
|
|
30
|
+
)?.provider
|
|
31
|
+
).toBe("appleMusic");
|
|
32
|
+
expect(
|
|
33
|
+
parseMoodTrackUrl("https://soundcloud.com/artist/track")?.provider
|
|
34
|
+
).toBe("soundcloud");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("normalizes empty to null and rejects unsupported hosts", () => {
|
|
38
|
+
expect(normalizeMoodTrackUrl(" ")).toBeNull();
|
|
39
|
+
expect(normalizeMoodTrackUrl(null)).toBeNull();
|
|
40
|
+
expect(() => normalizeMoodTrackUrl("https://example.com/song")).toThrow(
|
|
41
|
+
/YouTube Music/
|
|
42
|
+
);
|
|
43
|
+
expect(isMoodTrackUrlAllowed("")).toBe(true);
|
|
44
|
+
expect(isMoodTrackUrlAllowed("https://evil.example/x")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { DateTime } from "luxon";
|
|
2
|
-
|
|
3
1
|
/** Primary IANA zone for each US state + DC (contiguous conventions; AZ/HI/AK use canonical zones). */
|
|
4
2
|
const US_STATE_CODE_TO_IANA: Record<string, string> = {
|
|
5
3
|
AL: "America/Chicago",
|
|
@@ -84,7 +82,20 @@ export function getIanaTimezoneFromUsStateCode(
|
|
|
84
82
|
export function isValidIanaTimezone(timezone: string | null | undefined): boolean {
|
|
85
83
|
const t = timezone?.trim();
|
|
86
84
|
if (!t) return false;
|
|
87
|
-
|
|
85
|
+
|
|
86
|
+
// Reject ambiguous abbreviations (EST, PST, CST, etc.) - not IANA standard.
|
|
87
|
+
// These are ambiguous: EST could be US Eastern or Australian Eastern, etc.
|
|
88
|
+
// IANA zones follow "Region/City" format or special cases like "UTC".
|
|
89
|
+
const looksLikeAbbreviation = /^[A-Z]{2,5}T?$/.test(t);
|
|
90
|
+
if (looksLikeAbbreviation && t !== "UTC") return false;
|
|
91
|
+
|
|
92
|
+
// Intl throws RangeError for unknown IANA ids — no luxon needed.
|
|
93
|
+
try {
|
|
94
|
+
Intl.DateTimeFormat(undefined, { timeZone: t });
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/**
|
package/src/utils/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './entityUtils.js';
|
|
|
12
12
|
export * from './generalDateTimeUtils.js';
|
|
13
13
|
export * from './luxonUtils.js';
|
|
14
14
|
export * from './mathUtils.js';
|
|
15
|
+
export * from './moodTrackUtils.js';
|
|
15
16
|
export * from './objUtils.js';
|
|
16
17
|
export * from './paymentUtils.js';
|
|
17
18
|
export * from './promoCodesUtils.js';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash mood track — optional soundtrack URL on a bash.
|
|
3
|
+
* Preferred: YouTube Music. Also: Spotify, Apple Music, SoundCloud.
|
|
4
|
+
* Guests tap to play; never autoplay.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type MoodTrackProvider =
|
|
8
|
+
| "youtubeMusic"
|
|
9
|
+
| "spotify"
|
|
10
|
+
| "appleMusic"
|
|
11
|
+
| "soundcloud";
|
|
12
|
+
|
|
13
|
+
export type MoodTrackEmbed = {
|
|
14
|
+
provider: MoodTrackProvider;
|
|
15
|
+
/** Original pasted URL (normalized). */
|
|
16
|
+
url: string;
|
|
17
|
+
/** iframe src when the provider supports embed; null → link-out only. */
|
|
18
|
+
embedUrl: string | null;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const YOUTUBE_HOSTS = new Set([
|
|
23
|
+
"music.youtube.com",
|
|
24
|
+
"www.youtube.com",
|
|
25
|
+
"youtube.com",
|
|
26
|
+
"youtu.be",
|
|
27
|
+
"m.youtube.com",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const SPOTIFY_HOSTS = new Set(["open.spotify.com", "spotify.com"]);
|
|
31
|
+
const APPLE_HOSTS = new Set(["music.apple.com", "itunes.apple.com"]);
|
|
32
|
+
const SOUNDCLOUD_HOSTS = new Set(["soundcloud.com", "www.soundcloud.com", "m.soundcloud.com"]);
|
|
33
|
+
|
|
34
|
+
function hostnameOf(raw: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(raw).hostname.toLowerCase();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensureHttps(raw: string): string | null {
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
const withScheme = /^https?:\/\//i.test(trimmed)
|
|
46
|
+
? trimmed
|
|
47
|
+
: `https://${trimmed}`;
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(withScheme);
|
|
50
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
51
|
+
return u.toString();
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function youtubeVideoId(url: URL): string | null {
|
|
58
|
+
if (url.hostname === "youtu.be") {
|
|
59
|
+
const id = url.pathname.replace(/^\//, "").split("/")[0];
|
|
60
|
+
return id || null;
|
|
61
|
+
}
|
|
62
|
+
const v = url.searchParams.get("v");
|
|
63
|
+
if (v) return v;
|
|
64
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
65
|
+
const watchIdx = parts.indexOf("watch");
|
|
66
|
+
if (watchIdx >= 0 && parts[watchIdx + 1]) return parts[watchIdx + 1];
|
|
67
|
+
const embedIdx = parts.indexOf("embed");
|
|
68
|
+
if (embedIdx >= 0 && parts[embedIdx + 1]) return parts[embedIdx + 1];
|
|
69
|
+
const shortsIdx = parts.indexOf("shorts");
|
|
70
|
+
if (shortsIdx >= 0 && parts[shortsIdx + 1]) return parts[shortsIdx + 1];
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function youtubePlaylistId(url: URL): string | null {
|
|
75
|
+
return url.searchParams.get("list");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function spotifyEmbedPath(url: URL): string | null {
|
|
79
|
+
// /track/id | /album/id | /playlist/id | /episode/id
|
|
80
|
+
const m = url.pathname.match(
|
|
81
|
+
/^\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/
|
|
82
|
+
);
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
return `/${m[1]}/${m[2]}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function appleEmbedPath(url: URL): string | null {
|
|
88
|
+
// music.apple.com/{country}/album/... or /song/...
|
|
89
|
+
if (!url.pathname || url.pathname === "/") return null;
|
|
90
|
+
return url.pathname + url.search;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalize + validate a mood track URL for storage.
|
|
95
|
+
* Empty / whitespace → null. Unsupported host → throws with a host-facing message.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeMoodTrackUrl(
|
|
98
|
+
raw: string | null | undefined
|
|
99
|
+
): string | null {
|
|
100
|
+
if (raw == null) return null;
|
|
101
|
+
const trimmed = raw.trim();
|
|
102
|
+
if (!trimmed) return null;
|
|
103
|
+
const parsed = parseMoodTrackUrl(trimmed);
|
|
104
|
+
if (!parsed) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Use a YouTube Music, Spotify, Apple Music, or SoundCloud link."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return parsed.url;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a mood URL into an embed plan (or null if unsupported). */
|
|
113
|
+
export function parseMoodTrackUrl(
|
|
114
|
+
raw: string | null | undefined
|
|
115
|
+
): MoodTrackEmbed | null {
|
|
116
|
+
const https = ensureHttps(raw ?? "");
|
|
117
|
+
if (!https) return null;
|
|
118
|
+
let url: URL;
|
|
119
|
+
try {
|
|
120
|
+
url = new URL(https);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const host = hostnameOf(https);
|
|
125
|
+
if (!host) return null;
|
|
126
|
+
|
|
127
|
+
if (YOUTUBE_HOSTS.has(host)) {
|
|
128
|
+
const videoId = youtubeVideoId(url);
|
|
129
|
+
const listId = youtubePlaylistId(url);
|
|
130
|
+
if (videoId) {
|
|
131
|
+
const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
|
|
132
|
+
embed.searchParams.set("playsinline", "1");
|
|
133
|
+
embed.searchParams.set("rel", "0");
|
|
134
|
+
if (listId) embed.searchParams.set("list", listId);
|
|
135
|
+
return {
|
|
136
|
+
provider: "youtubeMusic",
|
|
137
|
+
url: https,
|
|
138
|
+
embedUrl: embed.toString(),
|
|
139
|
+
label: "YouTube Music",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (listId) {
|
|
143
|
+
return {
|
|
144
|
+
provider: "youtubeMusic",
|
|
145
|
+
url: https,
|
|
146
|
+
embedUrl: `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(listId)}`,
|
|
147
|
+
label: "YouTube Music",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (SPOTIFY_HOSTS.has(host)) {
|
|
154
|
+
const path = spotifyEmbedPath(url);
|
|
155
|
+
if (!path) return null;
|
|
156
|
+
return {
|
|
157
|
+
provider: "spotify",
|
|
158
|
+
url: https,
|
|
159
|
+
embedUrl: `https://open.spotify.com/embed${path}`,
|
|
160
|
+
label: "Spotify",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (APPLE_HOSTS.has(host)) {
|
|
165
|
+
const path = appleEmbedPath(url);
|
|
166
|
+
if (!path) return null;
|
|
167
|
+
return {
|
|
168
|
+
provider: "appleMusic",
|
|
169
|
+
url: https,
|
|
170
|
+
embedUrl: `https://embed.music.apple.com${path}`,
|
|
171
|
+
label: "Apple Music",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (SOUNDCLOUD_HOSTS.has(host)) {
|
|
176
|
+
return {
|
|
177
|
+
provider: "soundcloud",
|
|
178
|
+
url: https,
|
|
179
|
+
embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(https)}&auto_play=false&hide_related=true&show_comments=false&visual=false`,
|
|
180
|
+
label: "SoundCloud",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isMoodTrackUrlAllowed(raw: string | null | undefined): boolean {
|
|
188
|
+
if (raw == null || !String(raw).trim()) return true;
|
|
189
|
+
return parseMoodTrackUrl(raw) != null;
|
|
190
|
+
}
|