ether-to-astro 1.0.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/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
package/src/riseset.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { constants as Constants } from 'sweph';
|
|
2
|
+
import type { EphemerisCalculator } from './ephemeris.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { PLANET_NAMES, type RiseSetTime } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calculator for rise, set, and meridian transit times
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Calculates when celestial bodies rise above and set below the horizon,
|
|
11
|
+
* plus upper and lower meridian transits. Handles circumpolar objects
|
|
12
|
+
* and atmospheric refraction corrections.
|
|
13
|
+
*/
|
|
14
|
+
export class RiseSetCalculator {
|
|
15
|
+
/** Ephemeris calculator instance */
|
|
16
|
+
private ephem: EphemerisCalculator;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a new rise/set calculator
|
|
20
|
+
*
|
|
21
|
+
* @param ephem - Initialized ephemeris calculator
|
|
22
|
+
* @throws Error if ephemeris is not initialized
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* The ephemeris calculator must be initialized before passing
|
|
26
|
+
* to the RiseSetCalculator constructor.
|
|
27
|
+
*/
|
|
28
|
+
constructor(ephem: EphemerisCalculator) {
|
|
29
|
+
this.ephem = ephem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calculate rise, set, and meridian transit times for a celestial body
|
|
34
|
+
*
|
|
35
|
+
* Uses standard astronomical definitions:
|
|
36
|
+
* - Rise/Set: Upper limb of disc with atmospheric refraction considered
|
|
37
|
+
* - Atmospheric pressure: Estimated from altitude (sea level if altitude=0)
|
|
38
|
+
* - Temperature: 0°C (default assumption)
|
|
39
|
+
* - Upper meridian: Highest point in sky (culmination)
|
|
40
|
+
* - Lower meridian: Lowest point in sky (anti-culmination)
|
|
41
|
+
*
|
|
42
|
+
* Swiss Ephemeris return codes:
|
|
43
|
+
* - 0 or positive: Event found successfully
|
|
44
|
+
* - -1: Calculation error (hard failure)
|
|
45
|
+
* - -2: No event exists (circumpolar object)
|
|
46
|
+
*
|
|
47
|
+
* @param julianDay - Julian Day to start search from (typically midnight of target date)
|
|
48
|
+
* @param planetId - Swiss Ephemeris planet ID
|
|
49
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
50
|
+
* @param longitude - Observer longitude in degrees
|
|
51
|
+
* @param altitude - Observer altitude in meters (default: 0 = sea level)
|
|
52
|
+
* @returns Rise/set/transit times, or undefined fields if event doesn't occur
|
|
53
|
+
* @throws {Error} If ephemeris not initialized, invalid inputs, or hard calculation error
|
|
54
|
+
*/
|
|
55
|
+
calculateRiseSet(
|
|
56
|
+
julianDay: number,
|
|
57
|
+
planetId: number,
|
|
58
|
+
latitude: number,
|
|
59
|
+
longitude: number,
|
|
60
|
+
altitude: number = 0
|
|
61
|
+
): RiseSetTime {
|
|
62
|
+
if (!this.ephem.eph) {
|
|
63
|
+
throw new Error('Ephemeris not initialized');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Input validation
|
|
67
|
+
if (!Number.isFinite(julianDay)) {
|
|
68
|
+
throw new Error('Invalid Julian Day: must be a finite number');
|
|
69
|
+
}
|
|
70
|
+
if (latitude < -90 || latitude > 90) {
|
|
71
|
+
throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isFinite(longitude)) {
|
|
74
|
+
throw new Error('Invalid longitude: must be a finite number');
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isFinite(altitude)) {
|
|
77
|
+
throw new Error('Invalid altitude: must be a finite number');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const planetName = PLANET_NAMES[planetId];
|
|
81
|
+
if (!planetName) {
|
|
82
|
+
throw new Error(`Unknown planet ID: ${planetId}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result: RiseSetTime = {
|
|
86
|
+
planet: planetName,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Helper to call rise_trans and handle return codes
|
|
90
|
+
const calculateEvent = (eventType: number, eventName: string): Date | undefined => {
|
|
91
|
+
const eventResult = this.ephem.eph!.rise_trans(
|
|
92
|
+
julianDay,
|
|
93
|
+
planetId,
|
|
94
|
+
null,
|
|
95
|
+
Constants.SEFLG_SWIEPH,
|
|
96
|
+
eventType,
|
|
97
|
+
[longitude, latitude, altitude],
|
|
98
|
+
0, // atpress: 0 = auto-estimate from altitude
|
|
99
|
+
0 // attemp: 0°C
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (eventResult.flag === -1) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${eventName} calculation failed for ${planetName}: ${eventResult.error || 'Unknown error'}`
|
|
105
|
+
);
|
|
106
|
+
} else if (eventResult.flag === -2) {
|
|
107
|
+
logger.debug(`No ${eventName} for ${planetName} (circumpolar or no event)`, {
|
|
108
|
+
planet: planetName,
|
|
109
|
+
latitude,
|
|
110
|
+
});
|
|
111
|
+
return undefined;
|
|
112
|
+
} else if (Number.isFinite(eventResult.data)) {
|
|
113
|
+
return this.ephem.julianDayToDate(eventResult.data);
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
result.rise = calculateEvent(Constants.SE_CALC_RISE, 'rise');
|
|
119
|
+
result.set = calculateEvent(Constants.SE_CALC_SET, 'set');
|
|
120
|
+
result.upperMeridianTransit = calculateEvent(
|
|
121
|
+
Constants.SE_CALC_MTRANSIT,
|
|
122
|
+
'upper meridian transit'
|
|
123
|
+
);
|
|
124
|
+
result.lowerMeridianTransit = calculateEvent(
|
|
125
|
+
Constants.SE_CALC_ITRANSIT,
|
|
126
|
+
'lower meridian transit'
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get rise/set times for all planets for a given date
|
|
134
|
+
*
|
|
135
|
+
* @param date - Date/time to use as search anchor (typically current instant or midnight of target date)
|
|
136
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
137
|
+
* @param longitude - Observer longitude in degrees
|
|
138
|
+
* @param altitude - Observer altitude in meters (default: 0)
|
|
139
|
+
* @returns Array of rise/set times for all planets
|
|
140
|
+
* @throws Error if ephemeris not initialized or invalid inputs
|
|
141
|
+
*
|
|
142
|
+
* @remarks
|
|
143
|
+
* Calculates for Sun through Pluto. Some fields may be undefined
|
|
144
|
+
* for circumpolar objects at extreme latitudes.
|
|
145
|
+
*
|
|
146
|
+
* Swiss Ephemeris searches for the NEXT event after the given instant,
|
|
147
|
+
* so to get events for a specific civil date, pass midnight of that date.
|
|
148
|
+
*/
|
|
149
|
+
async getAllRiseSet(
|
|
150
|
+
date: Date,
|
|
151
|
+
latitude: number,
|
|
152
|
+
longitude: number,
|
|
153
|
+
altitude: number = 0
|
|
154
|
+
): Promise<RiseSetTime[]> {
|
|
155
|
+
// Validate shared inputs once - these are configuration errors, not planet-specific failures
|
|
156
|
+
if (!this.ephem.eph) {
|
|
157
|
+
throw new Error('Ephemeris not initialized');
|
|
158
|
+
}
|
|
159
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
160
|
+
throw new Error('Invalid date');
|
|
161
|
+
}
|
|
162
|
+
if (latitude < -90 || latitude > 90) {
|
|
163
|
+
throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
|
|
164
|
+
}
|
|
165
|
+
if (!Number.isFinite(longitude)) {
|
|
166
|
+
throw new Error('Invalid longitude: must be a finite number');
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isFinite(altitude)) {
|
|
169
|
+
throw new Error('Invalid altitude: must be a finite number');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const jd = this.ephem.dateToJulianDay(date);
|
|
173
|
+
const results: RiseSetTime[] = [];
|
|
174
|
+
|
|
175
|
+
// Calculate for Sun through Pluto (0-9)
|
|
176
|
+
// Only catch planet-specific computation failures (e.g., circumpolar edge cases)
|
|
177
|
+
for (let planetId = 0; planetId <= 9; planetId++) {
|
|
178
|
+
try {
|
|
179
|
+
const riseSet = this.calculateRiseSet(jd, planetId, latitude, longitude, altitude);
|
|
180
|
+
results.push(riseSet);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Planet-specific calculation failure - log and continue with other planets
|
|
183
|
+
logger.warn(`Failed to calculate rise/set for planet ${planetId}`, {
|
|
184
|
+
error: error instanceof Error ? error.message : String(error),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get Sun rise/set times for the current instant
|
|
194
|
+
*
|
|
195
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
196
|
+
* @param longitude - Observer longitude in degrees
|
|
197
|
+
* @param altitude - Observer altitude in meters (default: 0)
|
|
198
|
+
* @returns Rise/set times for the Sun
|
|
199
|
+
* @throws Error if ephemeris not initialized or invalid inputs
|
|
200
|
+
*
|
|
201
|
+
* @remarks
|
|
202
|
+
* Searches for the next sunrise/sunset after the current instant.
|
|
203
|
+
* If called in the afternoon, sunrise will be tomorrow.
|
|
204
|
+
* For events on a specific civil date, use calculateRiseSet with midnight JD.
|
|
205
|
+
*/
|
|
206
|
+
async getSunRiseSet(
|
|
207
|
+
latitude: number,
|
|
208
|
+
longitude: number,
|
|
209
|
+
altitude: number = 0
|
|
210
|
+
): Promise<RiseSetTime> {
|
|
211
|
+
// Validate inputs before calculation
|
|
212
|
+
if (!this.ephem.eph) {
|
|
213
|
+
throw new Error('Ephemeris not initialized');
|
|
214
|
+
}
|
|
215
|
+
if (latitude < -90 || latitude > 90) {
|
|
216
|
+
throw new Error(`Invalid latitude: ${latitude} (must be -90 to 90)`);
|
|
217
|
+
}
|
|
218
|
+
if (!Number.isFinite(longitude)) {
|
|
219
|
+
throw new Error('Invalid longitude: must be a finite number');
|
|
220
|
+
}
|
|
221
|
+
if (!Number.isFinite(altitude)) {
|
|
222
|
+
throw new Error('Invalid altitude: must be a finite number');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const now = new Date();
|
|
226
|
+
const jd = this.ephem.dateToJulianDay(now);
|
|
227
|
+
return this.calculateRiseSet(jd, 0, latitude, longitude, altitude); // Sun is planet ID 0
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time conversion utilities for astrology calculations
|
|
3
|
+
*
|
|
4
|
+
* Provides centralized time handling to ensure consistent conversion
|
|
5
|
+
* between local time and UTC across the entire codebase.
|
|
6
|
+
*
|
|
7
|
+
* DST Policy:
|
|
8
|
+
* - Nonexistent times (spring-forward gap): Use 'compatible' by default,
|
|
9
|
+
* which shifts forward. Use 'reject' for birth times to surface ambiguity.
|
|
10
|
+
* - Ambiguous times (fall-back overlap): Use 'compatible' by default,
|
|
11
|
+
* which prefers the earlier occurrence. Use 'reject' for birth times.
|
|
12
|
+
* - Offset sign convention: Positive = east of UTC, Negative = west of UTC
|
|
13
|
+
* (e.g., America/Los_Angeles = -480 winter, -420 summer; Asia/Tokyo = 540)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
17
|
+
|
|
18
|
+
export interface LocalDateTime {
|
|
19
|
+
year: number;
|
|
20
|
+
month: number; // 1-12 (not 0-indexed)
|
|
21
|
+
day: number;
|
|
22
|
+
hour: number; // 0-23
|
|
23
|
+
minute: number;
|
|
24
|
+
second?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert local time to UTC using timezone information
|
|
31
|
+
*
|
|
32
|
+
* @param local - Local date/time components
|
|
33
|
+
* @param timezone - IANA timezone string (e.g., 'America/New_York')
|
|
34
|
+
* @param disambiguation - How to handle DST ambiguity ('compatible' default, 'reject' for birth times)
|
|
35
|
+
* @returns UTC Date object
|
|
36
|
+
* @throws Error if timezone is invalid or time is ambiguous/nonexistent with 'reject'
|
|
37
|
+
*/
|
|
38
|
+
export function localToUTC(
|
|
39
|
+
local: LocalDateTime,
|
|
40
|
+
timezone: string,
|
|
41
|
+
disambiguation: Disambiguation = 'compatible'
|
|
42
|
+
): Date {
|
|
43
|
+
// Validate timezone first
|
|
44
|
+
if (!isValidTimezone(timezone)) {
|
|
45
|
+
throw new Error(`Invalid timezone: ${timezone}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build Temporal.PlainDateTime from LocalDateTime
|
|
49
|
+
const plainDateTime = Temporal.PlainDateTime.from({
|
|
50
|
+
year: local.year,
|
|
51
|
+
month: local.month,
|
|
52
|
+
day: local.day,
|
|
53
|
+
hour: local.hour,
|
|
54
|
+
minute: local.minute,
|
|
55
|
+
second: local.second ?? 0,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Convert to ZonedDateTime in the target timezone
|
|
59
|
+
const zonedDateTime = plainDateTime.toZonedDateTime(timezone, { disambiguation });
|
|
60
|
+
|
|
61
|
+
// Return as Date
|
|
62
|
+
return new Date(zonedDateTime.epochMilliseconds);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert UTC time to local time in specified timezone
|
|
67
|
+
*
|
|
68
|
+
* @param utc - UTC Date object
|
|
69
|
+
* @param timezone - IANA timezone string
|
|
70
|
+
* @returns Local date/time components
|
|
71
|
+
*/
|
|
72
|
+
export function utcToLocal(utc: Date, timezone: string): LocalDateTime {
|
|
73
|
+
// Convert Date to Temporal.Instant
|
|
74
|
+
const instant = Temporal.Instant.fromEpochMilliseconds(utc.getTime());
|
|
75
|
+
|
|
76
|
+
// Convert to ZonedDateTime in target timezone
|
|
77
|
+
const zonedDateTime = instant.toZonedDateTimeISO(timezone);
|
|
78
|
+
|
|
79
|
+
// Return numeric components
|
|
80
|
+
return {
|
|
81
|
+
year: zonedDateTime.year,
|
|
82
|
+
month: zonedDateTime.month,
|
|
83
|
+
day: zonedDateTime.day,
|
|
84
|
+
hour: zonedDateTime.hour,
|
|
85
|
+
minute: zonedDateTime.minute,
|
|
86
|
+
second: zonedDateTime.second,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate if a timezone string is valid
|
|
92
|
+
*
|
|
93
|
+
* @param timezone - Timezone string to validate
|
|
94
|
+
* @returns true if valid IANA timezone or UTC, false otherwise
|
|
95
|
+
*/
|
|
96
|
+
export function isValidTimezone(timezone: string): boolean {
|
|
97
|
+
if (!timezone || timezone.length === 0) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Validate by attempting to create a ZonedDateTime
|
|
103
|
+
// This accepts any valid IANA timezone identifier
|
|
104
|
+
const testDate = Temporal.PlainDateTime.from({
|
|
105
|
+
year: 2000,
|
|
106
|
+
month: 1,
|
|
107
|
+
day: 1,
|
|
108
|
+
hour: 0,
|
|
109
|
+
minute: 0,
|
|
110
|
+
});
|
|
111
|
+
testDate.toZonedDateTime(timezone);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get timezone offset in minutes for a specific date
|
|
120
|
+
* Handles DST transitions correctly
|
|
121
|
+
*
|
|
122
|
+
* @param date - Date to get offset for
|
|
123
|
+
* @param timezone - IANA timezone string
|
|
124
|
+
* @returns Offset in minutes (positive = east of UTC, negative = west of UTC)
|
|
125
|
+
* Examples: America/Los_Angeles winter = -480, summer = -420; Asia/Tokyo = 540
|
|
126
|
+
*/
|
|
127
|
+
export function getTimezoneOffset(date: Date, timezone: string): number {
|
|
128
|
+
// Convert Date to Temporal.Instant
|
|
129
|
+
const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
|
|
130
|
+
|
|
131
|
+
// Convert to ZonedDateTime in target timezone
|
|
132
|
+
const zonedDateTime = instant.toZonedDateTimeISO(timezone);
|
|
133
|
+
|
|
134
|
+
// Get offset from the ZonedDateTime itself
|
|
135
|
+
// offsetNanoseconds is positive for east, negative for west
|
|
136
|
+
const offsetMinutes = zonedDateTime.offsetNanoseconds / (1000 * 1000 * 1000 * 60);
|
|
137
|
+
|
|
138
|
+
return offsetMinutes;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Add calendar days to a local date in a specific timezone
|
|
143
|
+
* Properly handles month/year rollovers and DST transitions
|
|
144
|
+
*
|
|
145
|
+
* @param local - Starting local date/time
|
|
146
|
+
* @param timezone - IANA timezone string
|
|
147
|
+
* @param days - Number of days to add (can be negative)
|
|
148
|
+
* @returns UTC Date representing the new local date/time
|
|
149
|
+
*/
|
|
150
|
+
export function addLocalDays(local: LocalDateTime, timezone: string, days: number): Date {
|
|
151
|
+
// Convert to Temporal for proper calendar math
|
|
152
|
+
const plainDateTime = Temporal.PlainDateTime.from({
|
|
153
|
+
year: local.year,
|
|
154
|
+
month: local.month,
|
|
155
|
+
day: local.day,
|
|
156
|
+
hour: local.hour,
|
|
157
|
+
minute: local.minute,
|
|
158
|
+
second: local.second ?? 0,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Add days using Temporal's calendar-aware addition
|
|
162
|
+
const newPlainDateTime = plainDateTime.add({ days });
|
|
163
|
+
|
|
164
|
+
// Convert back to UTC via the timezone
|
|
165
|
+
const zonedDateTime = newPlainDateTime.toZonedDateTime(timezone);
|
|
166
|
+
return new Date(zonedDateTime.epochMilliseconds);
|
|
167
|
+
}
|