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