diff --git a/.claude/references/ministryplatform.datetimehandling.md b/.claude/references/ministryplatform.datetimehandling.md new file mode 100644 index 0000000..ae88778 --- /dev/null +++ b/.claude/references/ministryplatform.datetimehandling.md @@ -0,0 +1,132 @@ +# MP Date/Time Handling Reference + +This document covers how date and datetime values must flow between the UI, our services, and the Ministry Platform (MP) API. Use it whenever you add a new MP date field, audit a server action that writes dates, or debug a "the saved date is wrong" report. + +## Why MP is not UTC + +MP stores datetimes as **wall-clock values in the domain's configured time zone** (e.g. `2026-05-17 23:33:00` is literally "11:33 PM in this church's time zone"). It does **not** normalize to UTC on the way in or out. The domain's time zone is exposed via `MPHelper.getDomainInfo().TimeZoneName`. + +If you send a value tagged as UTC, MP stores it as if those UTC clock numbers were the local clock numbers — the saved record drifts by the MP-to-UTC offset. The same anti-pattern in reverse on the read path causes drift on display and compounds across edits. + +A real symptom of this bug pattern: a Contact Log entry created at 11:33 PM Eastern on 2026-05-17 saves as 2026-05-16 at 8:00 PM. The form appended `T00:00:00.000Z` to a date string, and the service ran `new Date(...).getFullYear()` on the result. Each save shifted the date by the offset between the Node server's local time and UTC. Editing read the already-shifted date and applied the same transform again, so the date moved backwards another day every edit. + +## The service + +`src/services/domainTimezoneService.ts` — singleton, server-side, cached per process. Always go through this; never reach into `MPHelper.getDomainInfo()` directly to read `TimeZoneName`. + +```ts +import { DomainTimezoneService } from '@/services/domainTimezoneService'; + +const tz = DomainTimezoneService.getInstance(); +await tz.getMpTimezone(); // → "America/New_York" (IANA) +await tz.toMpSqlDatetime('2026-05-17'); // → "2026-05-17 00:00:00" +await tz.toMpSqlDatetime(new Date()); // → MP-TZ wall-clock for "now" +await tz.parseMpDatetime('2026-05-17 12:00:00'); // → Date instant +``` + +For client-side rendering, expose the IANA zone through `getMpTimezone()` in `src/components/shared-actions/domain.ts` and thread it as a prop into the component that needs to format MP datetimes. + +### `toMpSqlDatetime(value)` — write path + +Returns the SQL datetime string MP's table API expects (`YYYY-MM-DD HH:MM:SS`). + +| Input | Treated as | Output | +| --- | --- | --- | +| `"2026-05-17"` | MP-TZ wall-clock midnight | `"2026-05-17 00:00:00"` | +| `"2026-05-17 14:30:00"` | MP-TZ wall-clock (already SQL) | `"2026-05-17 14:30:00"` | +| `"2026-05-17T14:30"` | MP-TZ wall-clock | `"2026-05-17 14:30:00"` | +| `"2026-05-17T03:33:00.000Z"` | UTC instant | converted to MP-TZ | +| `"2026-05-17T03:33:00-04:00"` | Instant at offset | converted to MP-TZ | +| `Date` instance | UTC instant | converted to MP-TZ | + +The rule: **strings with no zone marker are wall-clock**, strings/Dates with explicit zone info are instants that get converted. + +### `parseMpDatetime(value)` — read path arithmetic + +Use when you need a `Date` instant to do real arithmetic on a value MP returned. For pure display, prefer `Intl.DateTimeFormat({ timeZone })` against the raw string. + +## Recipes + +### Writing a date-only field (``) + +```tsx +// Client component — send the raw string, no Z, no time. +const payload = { Contact_Date: form.contactDate /* "2026-05-17" */ }; + +// Server action / service +const tz = DomainTimezoneService.getInstance(); +const mpDate = await tz.toMpSqlDatetime(payload.Contact_Date); +// → "2026-05-17 00:00:00" +``` + +### Writing a datetime field with a "save at current moment" intent + +```ts +const tz = DomainTimezoneService.getInstance(); +const mpDate = await tz.toMpSqlDatetime(new Date()); +// → MP-TZ wall-clock representation of the server's "now" +``` + +### Pre-filling an edit form from a stored MP value + +MP returns datetimes as wall-clock strings in MP-TZ (no zone marker). For a date input, take the date portion directly — **do not** parse with `new Date()`: + +```tsx +setValue('contactDate', log.Contact_Date.split('T')[0]); +``` + +For a `datetime-local` input, trim to `YYYY-MM-DDTHH:MM`: + +```tsx +function toDatetimeLocalValue(mpDate: string): string { + const normalized = mpDate.replace(' ', 'T'); + return normalized.length >= 16 ? normalized.slice(0, 16) : `${normalized.slice(0, 10)}T00:00`; +} +``` + +### Displaying a stored MP datetime in the browser + +`new Date(stringFromMp).toLocaleDateString(...)` parses the string as **browser-local**, which silently disagrees with MP-TZ. Format with an explicit `timeZone`: + +```tsx +return new Intl.DateTimeFormat('en-US', { + timeZone: mpTimezone, + month: 'short', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', +}).format(instant); +``` + +### Filtering on a date column in `$filter` + +`$filter` strings are interpreted in MP-TZ. Quote the value and use MP-TZ wall-clock: + +```ts +filter: `Contact_Date >= '2026-05-01' AND Contact_Date < '2026-06-01'` +``` + +Do not convert filter values to UTC. If you have a `Date` instant in JS, run it through `tz.toMpSqlDatetime(instant)` first. + +## Anti-patterns + +| Don't | Do | +| --- | --- | +| ``Contact_Date: `${date}T00:00:00.000Z` `` | `Contact_Date: date` | +| `new Date(formValue).toISOString()` | `await tz.toMpSqlDatetime(formValue)` | +| `new Date(mpValue).getFullYear()` etc. | `await tz.parseMpDatetime(mpValue)` or `Intl.DateTimeFormat({ timeZone })` | +| `new Date(mpValue).toLocaleString(...)` for display | `Intl.DateTimeFormat('en-US', { timeZone: mpTimezone, ... })` | +| Reading domain TZ ad-hoc per request | `DomainTimezoneService.getInstance().getMpTimezone()` (cached) | + +The shared signature of these bugs: a `Date` object that crosses a zone boundary silently. Whenever you see `new Date(...)` near an MP read/write, ask "what zone is this assumed to be in, and what zone is the caller expecting back?" + +## Windows ↔ IANA zone names + +MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them. If a new MP deployment surfaces an unmapped zone, `resolveIanaTimezone` throws with the unmapped name — extend the table rather than silently falling back to the server's local zone. + +## Testing + +When a test exercises code that goes through `DomainTimezoneService`: + +1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — use `vi.hoisted()` because the service's `MPHelper` is constructed at module-load time. +2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach`. +3. **Use `mockReset()` (not `clearAllMocks()`)** on the `getDomainInfo` mock when you queue per-test responses with `mockResolvedValueOnce`. `clearAllMocks` doesn't drain those queues, and tests that don't hit `getMpTimezone()` leave queue entries behind that leak forward. +4. **Run under multiple `TZ` env vars** — at minimum `TZ=UTC` and `TZ=America/Los_Angeles`. The original bug was invisible when developer machines and the server happened to be in the same zone as the MP domain. diff --git a/CLAUDE.md b/CLAUDE.md index d06996b..f256b8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,7 @@ export default MyComponent; // ❌ Avoid 9. **Use service classes in server actions** - call services from `src/services/`, not MPHelper directly from components or actions 10. **Disambiguate ambiguous columns** - when querying tables with FK joins, prefix columns that exist in multiple tables (e.g., `Contacts.Contact_ID` not just `Contact_ID`). Use `FKColumn_TABLE.Column` to traverse foreign keys (e.g., `Contact_ID_TABLE.First_Name`). For multi-level FK traversal, chain with `_TABLE_` underscores and use a dot only before the final field (e.g., `Building_ID_TABLE_Location_ID_TABLE.Congregation_ID`). See **[Services query-patterns](.claude/references/services/query-patterns.md)** for full rules and examples. 11. **Escape user input in filters** - always escape single quotes: `term.replace(/'/g, "''")` +12. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()`, `` `${date}T00:00:00Z` ``, or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. ## Validation Best Practices @@ -214,6 +215,7 @@ Agent-facing reference docs are hierarchical under `.claude/references/`. Start - **[GLOSSARY](.claude/references/GLOSSARY.md)** — domain terms (alphabetized) - **[DECISIONS](.claude/references/DECISIONS.md)** — architectural decisions (ADRs) - **[GOTCHAS](.claude/references/GOTCHAS.md)** — known traps (symptom-first) +- **[Ministry Platform Date/Time Handling](.claude/references/ministryplatform.datetimehandling.md)** — How to send/receive MP datetimes safely via `DomainTimezoneService`, anti-patterns, Windows↔IANA mapping, and test guidance ### Domain subfolders diff --git a/src/components/shared-actions/domain.ts b/src/components/shared-actions/domain.ts new file mode 100644 index 0000000..c75c1dc --- /dev/null +++ b/src/components/shared-actions/domain.ts @@ -0,0 +1,16 @@ +'use server'; + +import { DomainTimezoneService } from '@/services/domainTimezoneService'; + +/** + * Returns the IANA time zone identifier for the active Ministry Platform + * domain. Use this to drive any client-side `Intl.DateTimeFormat` rendering + * of MP-sourced datetime values so the displayed wall-clock matches MP's + * database regardless of the user's browser zone. + * + * Result is cached for the lifetime of the server process. + */ +export async function getMpTimezone(): Promise { + const tz = DomainTimezoneService.getInstance(); + return tz.getMpTimezone(); +} diff --git a/src/services/domainTimezoneService.test.ts b/src/services/domainTimezoneService.test.ts new file mode 100644 index 0000000..1399c0f --- /dev/null +++ b/src/services/domainTimezoneService.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockGetDomainInfo } = vi.hoisted(() => ({ + mockGetDomainInfo: vi.fn(), +})); + +vi.mock('@/lib/providers/ministry-platform', () => { + return { + MPHelper: class { + getDomainInfo = mockGetDomainInfo; + }, + }; +}); + +import { + DomainTimezoneService, + resolveIanaTimezone, +} from '@/services/domainTimezoneService'; + +function freshService(): DomainTimezoneService { + + (DomainTimezoneService as any).instance = null; + return DomainTimezoneService.getInstance(); +} + +describe('resolveIanaTimezone', () => { + it('maps common Windows zone names to IANA', () => { + expect(resolveIanaTimezone('Eastern Standard Time')).toBe('America/New_York'); + expect(resolveIanaTimezone('Central Standard Time')).toBe('America/Chicago'); + expect(resolveIanaTimezone('Pacific Standard Time')).toBe('America/Los_Angeles'); + expect(resolveIanaTimezone('GMT Standard Time')).toBe('Europe/London'); + }); + + it('passes through IANA zone names unchanged', () => { + expect(resolveIanaTimezone('America/Chicago')).toBe('America/Chicago'); + expect(resolveIanaTimezone('Europe/Berlin')).toBe('Europe/Berlin'); + }); + + it('normalizes UTC variants', () => { + expect(resolveIanaTimezone('UTC')).toBe('Etc/UTC'); + expect(resolveIanaTimezone('Etc/UTC')).toBe('Etc/UTC'); + }); + + it('throws for unknown identifiers rather than silently falling back', () => { + expect(() => resolveIanaTimezone('Atlantis Standard Time')).toThrow(/Unknown time zone/); + expect(() => resolveIanaTimezone('')).toThrow(); + }); +}); + +describe('DomainTimezoneService', () => { + beforeEach(() => { + // Use mockReset (not clearAllMocks) so mockResolvedValueOnce queues are + // drained between tests. Date-only paths skip getMpTimezone() and would + // otherwise leak unconsumed queue entries forward. + mockGetDomainInfo.mockReset(); + }); + + describe('getMpTimezone', () => { + it('fetches and caches the IANA zone after first call', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ + TimeZoneName: 'Eastern Standard Time', + DisplayName: 'Test', + CultureName: 'en-US', + }); + const svc = freshService(); + expect(await svc.getMpTimezone()).toBe('America/New_York'); + expect(await svc.getMpTimezone()).toBe('America/New_York'); + expect(mockGetDomainInfo).toHaveBeenCalledTimes(1); + }); + + it('accepts an IANA zone from MP without mapping', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'America/Chicago' }); + const svc = freshService(); + expect(await svc.getMpTimezone()).toBe('America/Chicago'); + }); + + it('deduplicates concurrent first calls', async () => { + let resolveFn!: (v: { TimeZoneName: string }) => void; + mockGetDomainInfo.mockReturnValueOnce( + new Promise((res) => { + resolveFn = res; + }), + ); + const svc = freshService(); + const a = svc.getMpTimezone(); + const b = svc.getMpTimezone(); + resolveFn({ TimeZoneName: 'Eastern Standard Time' }); + expect(await a).toBe('America/New_York'); + expect(await b).toBe('America/New_York'); + expect(mockGetDomainInfo).toHaveBeenCalledTimes(1); + }); + }); + + describe('toMpSqlDatetime', () => { + it('reformats a date-only string as MP-TZ midnight without conversion', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'Eastern Standard Time' }); + const svc = freshService(); + expect(await svc.toMpSqlDatetime('2026-05-17')).toBe('2026-05-17 00:00:00'); + }); + + it('preserves an already-SQL wall-clock value (no UTC math)', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'Eastern Standard Time' }); + const svc = freshService(); + expect(await svc.toMpSqlDatetime('2026-05-17 23:33:00')).toBe('2026-05-17 23:33:00'); + }); + + it('preserves a T-separated wall-clock value', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'Eastern Standard Time' }); + const svc = freshService(); + expect(await svc.toMpSqlDatetime('2026-05-17T14:30')).toBe('2026-05-17 14:30:00'); + }); + + it('converts a UTC-tagged instant into MP-TZ wall-clock', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'America/New_York' }); + const svc = freshService(); + expect(await svc.toMpSqlDatetime('2026-05-17T03:33:00.000Z')).toBe('2026-05-16 23:33:00'); + }); + + it('converts a Date instant into MP-TZ wall-clock', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'America/Los_Angeles' }); + const svc = freshService(); + const instant = new Date('2026-05-17T03:33:00.000Z'); + expect(await svc.toMpSqlDatetime(instant)).toBe('2026-05-16 20:33:00'); + }); + + it('regression: date-only input does NOT shift when server is in a different TZ', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'America/New_York' }); + const svc = freshService(); + expect(await svc.toMpSqlDatetime('2026-05-17')).toBe('2026-05-17 00:00:00'); + }); + + it('throws for unparseable input', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'Eastern Standard Time' }); + const svc = freshService(); + await expect(svc.toMpSqlDatetime('not a date')).rejects.toThrow(); + await expect(svc.toMpSqlDatetime('')).rejects.toThrow(); + }); + }); + + describe('parseMpDatetime', () => { + it('treats a wall-clock string as MP-TZ and returns the matching UTC instant', async () => { + mockGetDomainInfo.mockResolvedValueOnce({ TimeZoneName: 'America/New_York' }); + const svc = freshService(); + const instant = await svc.parseMpDatetime('2026-05-17 12:00:00'); + expect(instant.toISOString()).toBe('2026-05-17T16:00:00.000Z'); + }); + + it('respects an explicit Z marker', async () => { + const svc = freshService(); + const instant = await svc.parseMpDatetime('2026-05-17T03:33:00.000Z'); + expect(instant.toISOString()).toBe('2026-05-17T03:33:00.000Z'); + }); + }); +}); diff --git a/src/services/domainTimezoneService.ts b/src/services/domainTimezoneService.ts new file mode 100644 index 0000000..a568ba8 --- /dev/null +++ b/src/services/domainTimezoneService.ts @@ -0,0 +1,344 @@ +import { MPHelper } from '@/lib/providers/ministry-platform'; + +/** + * Mapping of common Windows time zone IDs (as returned by the MP /domain endpoint's + * `TimeZoneName` field) to IANA time zone identifiers (which `Intl.DateTimeFormat` + * requires). Extend as new MP-hosted domains surface zones not listed here. + */ +const WINDOWS_TO_IANA: Record = { + 'Dateline Standard Time': 'Etc/GMT+12', + 'UTC-11': 'Etc/GMT+11', + 'Aleutian Standard Time': 'America/Adak', + 'Hawaiian Standard Time': 'Pacific/Honolulu', + 'Marquesas Standard Time': 'Pacific/Marquesas', + 'Alaskan Standard Time': 'America/Anchorage', + 'UTC-09': 'Etc/GMT+9', + 'Pacific Standard Time (Mexico)': 'America/Tijuana', + 'UTC-08': 'Etc/GMT+8', + 'Pacific Standard Time': 'America/Los_Angeles', + 'US Mountain Standard Time': 'America/Phoenix', + 'Mountain Standard Time (Mexico)': 'America/Mazatlan', + 'Mountain Standard Time': 'America/Denver', + 'Central America Standard Time': 'America/Guatemala', + 'Central Standard Time': 'America/Chicago', + 'Easter Island Standard Time': 'Pacific/Easter', + 'Central Standard Time (Mexico)': 'America/Mexico_City', + 'Canada Central Standard Time': 'America/Regina', + 'SA Pacific Standard Time': 'America/Bogota', + 'Eastern Standard Time (Mexico)': 'America/Cancun', + 'Eastern Standard Time': 'America/New_York', + 'Haiti Standard Time': 'America/Port-au-Prince', + 'Cuba Standard Time': 'America/Havana', + 'US Eastern Standard Time': 'America/Indianapolis', + 'Turks And Caicos Standard Time': 'America/Grand_Turk', + 'Paraguay Standard Time': 'America/Asuncion', + 'Atlantic Standard Time': 'America/Halifax', + 'Venezuela Standard Time': 'America/Caracas', + 'Central Brazilian Standard Time': 'America/Cuiaba', + 'SA Western Standard Time': 'America/La_Paz', + 'Pacific SA Standard Time': 'America/Santiago', + 'Newfoundland Standard Time': 'America/St_Johns', + 'Tocantins Standard Time': 'America/Araguaina', + 'E. South America Standard Time': 'America/Sao_Paulo', + 'SA Eastern Standard Time': 'America/Cayenne', + 'Argentina Standard Time': 'America/Buenos_Aires', + 'Greenland Standard Time': 'America/Godthab', + 'Montevideo Standard Time': 'America/Montevideo', + 'Magallanes Standard Time': 'America/Punta_Arenas', + 'Saint Pierre Standard Time': 'America/Miquelon', + 'Bahia Standard Time': 'America/Bahia', + 'UTC-02': 'Etc/GMT+2', + 'Azores Standard Time': 'Atlantic/Azores', + 'Cape Verde Standard Time': 'Atlantic/Cape_Verde', + UTC: 'Etc/UTC', + 'GMT Standard Time': 'Europe/London', + 'Greenwich Standard Time': 'Atlantic/Reykjavik', + 'Sao Tome Standard Time': 'Africa/Sao_Tome', + 'Morocco Standard Time': 'Africa/Casablanca', + 'W. Europe Standard Time': 'Europe/Berlin', + 'Central Europe Standard Time': 'Europe/Budapest', + 'Romance Standard Time': 'Europe/Paris', + 'Central European Standard Time': 'Europe/Warsaw', + 'W. Central Africa Standard Time': 'Africa/Lagos', + 'Jordan Standard Time': 'Asia/Amman', + 'GTB Standard Time': 'Europe/Bucharest', + 'Middle East Standard Time': 'Asia/Beirut', + 'Egypt Standard Time': 'Africa/Cairo', + 'E. Europe Standard Time': 'Europe/Chisinau', + 'Syria Standard Time': 'Asia/Damascus', + 'West Bank Standard Time': 'Asia/Hebron', + 'South Africa Standard Time': 'Africa/Johannesburg', + 'FLE Standard Time': 'Europe/Kiev', + 'Israel Standard Time': 'Asia/Jerusalem', + 'Kaliningrad Standard Time': 'Europe/Kaliningrad', + 'Sudan Standard Time': 'Africa/Khartoum', + 'Libya Standard Time': 'Africa/Tripoli', + 'Namibia Standard Time': 'Africa/Windhoek', + 'Arabic Standard Time': 'Asia/Baghdad', + 'Turkey Standard Time': 'Europe/Istanbul', + 'Arab Standard Time': 'Asia/Riyadh', + 'Belarus Standard Time': 'Europe/Minsk', + 'Russian Standard Time': 'Europe/Moscow', + 'E. Africa Standard Time': 'Africa/Nairobi', + 'Iran Standard Time': 'Asia/Tehran', + 'Arabian Standard Time': 'Asia/Dubai', + 'Astrakhan Standard Time': 'Europe/Astrakhan', + 'Azerbaijan Standard Time': 'Asia/Baku', + 'Russia Time Zone 3': 'Europe/Samara', + 'Mauritius Standard Time': 'Indian/Mauritius', + 'Saratov Standard Time': 'Europe/Saratov', + 'Georgian Standard Time': 'Asia/Tbilisi', + 'Volgograd Standard Time': 'Europe/Volgograd', + 'Caucasus Standard Time': 'Asia/Yerevan', + 'Afghanistan Standard Time': 'Asia/Kabul', + 'West Asia Standard Time': 'Asia/Tashkent', + 'Ekaterinburg Standard Time': 'Asia/Yekaterinburg', + 'Pakistan Standard Time': 'Asia/Karachi', + 'Qyzylorda Standard Time': 'Asia/Qyzylorda', + 'India Standard Time': 'Asia/Calcutta', + 'Sri Lanka Standard Time': 'Asia/Colombo', + 'Nepal Standard Time': 'Asia/Katmandu', + 'Central Asia Standard Time': 'Asia/Almaty', + 'Bangladesh Standard Time': 'Asia/Dhaka', + 'Omsk Standard Time': 'Asia/Omsk', + 'Myanmar Standard Time': 'Asia/Rangoon', + 'SE Asia Standard Time': 'Asia/Bangkok', + 'Altai Standard Time': 'Asia/Barnaul', + 'W. Mongolia Standard Time': 'Asia/Hovd', + 'North Asia Standard Time': 'Asia/Krasnoyarsk', + 'N. Central Asia Standard Time': 'Asia/Novosibirsk', + 'Tomsk Standard Time': 'Asia/Tomsk', + 'China Standard Time': 'Asia/Shanghai', + 'North Asia East Standard Time': 'Asia/Irkutsk', + 'Singapore Standard Time': 'Asia/Singapore', + 'W. Australia Standard Time': 'Australia/Perth', + 'Taipei Standard Time': 'Asia/Taipei', + 'Ulaanbaatar Standard Time': 'Asia/Ulaanbaatar', + 'Aus Central W. Standard Time': 'Australia/Eucla', + 'Transbaikal Standard Time': 'Asia/Chita', + 'Tokyo Standard Time': 'Asia/Tokyo', + 'North Korea Standard Time': 'Asia/Pyongyang', + 'Korea Standard Time': 'Asia/Seoul', + 'Yakutsk Standard Time': 'Asia/Yakutsk', + 'Cen. Australia Standard Time': 'Australia/Adelaide', + 'AUS Central Standard Time': 'Australia/Darwin', + 'E. Australia Standard Time': 'Australia/Brisbane', + 'AUS Eastern Standard Time': 'Australia/Sydney', + 'West Pacific Standard Time': 'Pacific/Port_Moresby', + 'Tasmania Standard Time': 'Australia/Hobart', + 'Vladivostok Standard Time': 'Asia/Vladivostok', + 'Lord Howe Standard Time': 'Australia/Lord_Howe', + 'Bougainville Standard Time': 'Pacific/Bougainville', + 'Russia Time Zone 10': 'Asia/Srednekolymsk', + 'Magadan Standard Time': 'Asia/Magadan', + 'Norfolk Standard Time': 'Pacific/Norfolk', + 'Sakhalin Standard Time': 'Asia/Sakhalin', + 'Central Pacific Standard Time': 'Pacific/Guadalcanal', + 'Russia Time Zone 11': 'Asia/Kamchatka', + 'New Zealand Standard Time': 'Pacific/Auckland', + 'UTC+12': 'Etc/GMT-12', + 'Fiji Standard Time': 'Pacific/Fiji', + 'Chatham Islands Standard Time': 'Pacific/Chatham', + 'UTC+13': 'Etc/GMT-13', + 'Tonga Standard Time': 'Pacific/Tongatapu', + 'Samoa Standard Time': 'Pacific/Apia', + 'Line Islands Standard Time': 'Pacific/Kiritimati', +}; + +/** + * Resolves an MP-provided time zone identifier to an IANA name. Accepts either a + * Windows zone (MP's typical output, e.g. "Eastern Standard Time") or an IANA + * name already (e.g. "America/New_York"). Throws if the value is unknown so + * callers fail fast rather than silently drift to the server's local zone. + */ +export function resolveIanaTimezone(timeZone: string): string { + if (!timeZone || typeof timeZone !== 'string') { + throw new Error('Time zone identifier is required'); + } + const trimmed = timeZone.trim(); + if (trimmed.length === 0) { + throw new Error('Time zone identifier is required'); + } + if (trimmed === 'UTC' || trimmed === 'Etc/UTC') { + return 'Etc/UTC'; + } + if (trimmed.includes('/')) { + return trimmed; + } + const mapped = WINDOWS_TO_IANA[trimmed]; + if (!mapped) { + throw new Error( + `Unknown time zone "${trimmed}" — add it to the Windows→IANA mapping in domainTimezoneService.ts`, + ); + } + return mapped; +} + +function parseWallClockParts(value: string): { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} | null { + const trimmed = value.trim(); + if (/Z$/.test(trimmed) || /[+-]\d{2}:?\d{2}$/.test(trimmed)) { + return null; + } + const match = trimmed.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?$/, + ); + if (!match) { + return null; + } + const [, y, mo, d, h = '00', mi = '00', s = '00'] = match; + return { + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(h), + minute: Number(mi), + second: Number(s), + }; +} + +function formatInstantAsMpSql(instant: Date, ianaTimeZone: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: ianaTimeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(instant); + const lookup: Record = {}; + for (const part of parts) { + lookup[part.type] = part.value; + } + // Some ICU builds emit "24" for midnight under hour12:false; normalize. + const hour = lookup.hour === '24' ? '00' : lookup.hour; + return `${lookup.year}-${lookup.month}-${lookup.day} ${hour}:${lookup.minute}:${lookup.second}`; +} + +/** + * DomainTimezoneService — singleton helper for converting date/time values + * between MP's domain time zone and the application's various surfaces. + * + * Why this exists: MP stores datetimes as wall-clock values in the domain's + * configured time zone (NOT UTC). Sending a UTC-tagged value or letting + * `new Date(...).getFullYear()` round-trip through the server's local time + * silently shifts dates by the offset between server and MP. + */ +export class DomainTimezoneService { + private static instance: DomainTimezoneService | null = null; + private mp: MPHelper; + private cachedIana: string | null = null; + private inflight: Promise | null = null; + + private constructor() { + this.mp = new MPHelper(); + } + + public static getInstance(): DomainTimezoneService { + if (!DomainTimezoneService.instance) { + DomainTimezoneService.instance = new DomainTimezoneService(); + } + return DomainTimezoneService.instance; + } + + public async getMpTimezone(): Promise { + if (this.cachedIana) { + return this.cachedIana; + } + if (!this.inflight) { + this.inflight = (async () => { + const info = await this.mp.getDomainInfo(); + const iana = resolveIanaTimezone(info.TimeZoneName); + this.cachedIana = iana; + return iana; + })().finally(() => { + this.inflight = null; + }); + } + return this.inflight; + } + + /** + * Converts a value into the SQL datetime string MP's table API expects + * ("YYYY-MM-DD HH:MM:SS" in the MP domain's wall-clock time). + * + * - Wall-clock string with no zone marker → reformatted as MP-TZ wall-clock, + * missing components default to zero. + * - String with trailing "Z" or "±HH:MM" offset → parsed as a UTC/offset + * instant and converted into MP-TZ wall-clock. + * - `Date` instances → converted as UTC instants. + */ + public async toMpSqlDatetime(value: Date | string): Promise { + if (value instanceof Date) { + const iana = await this.getMpTimezone(); + return formatInstantAsMpSql(value, iana); + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('toMpSqlDatetime: value must be a non-empty string or Date'); + } + const wallClock = parseWallClockParts(value); + if (wallClock) { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${wallClock.year}-${pad(wallClock.month)}-${pad(wallClock.day)} ${pad(wallClock.hour)}:${pad(wallClock.minute)}:${pad(wallClock.second)}`; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`toMpSqlDatetime: unable to parse "${value}"`); + } + const iana = await this.getMpTimezone(); + return formatInstantAsMpSql(parsed, iana); + } + + /** + * Parses an MP wall-clock datetime string into a `Date` instant. Use when + * you need real arithmetic on values returned from MP — for display, prefer + * `Intl.DateTimeFormat({ timeZone })` directly against the raw string. + */ + public async parseMpDatetime(value: string): Promise { + const wallClock = parseWallClockParts(value); + if (!wallClock) { + const direct = new Date(value); + if (Number.isNaN(direct.getTime())) { + throw new Error(`parseMpDatetime: unable to parse "${value}"`); + } + return direct; + } + const iana = await this.getMpTimezone(); + const utcGuess = Date.UTC( + wallClock.year, + wallClock.month - 1, + wallClock.day, + wallClock.hour, + wallClock.minute, + wallClock.second, + ); + const projected = formatInstantAsMpSql(new Date(utcGuess), iana); + const projectedParts = parseWallClockParts(projected)!; + const projectedUtc = Date.UTC( + projectedParts.year, + projectedParts.month - 1, + projectedParts.day, + projectedParts.hour, + projectedParts.minute, + projectedParts.second, + ); + const offset = utcGuess - projectedUtc; + return new Date(utcGuess + offset); + } + + /** Test hook — clears cached domain info so the next call refetches. */ + public clearCache(): void { + this.cachedIana = null; + this.inflight = null; + } +} + +export const domainTimezoneService = DomainTimezoneService.getInstance(); diff --git a/src/services/familyService.ts b/src/services/familyService.ts index 7e0b50f..6ce3f72 100644 --- a/src/services/familyService.ts +++ b/src/services/familyService.ts @@ -1,5 +1,6 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; import { escapeFilterString, validatePositiveInt, validateColumnName } from "@/lib/validation"; +import { DomainTimezoneService } from "@/services/domainTimezoneService"; import type { ContactSearchResult, CountryOption, @@ -590,6 +591,8 @@ export class FamilyService { { partial: true, $userId: userId }, ); } else { + const tz = DomainTimezoneService.getInstance(); + const nowMpSql = await tz.toMpSqlDatetime(new Date()); const created = await this.mp!.createTableRecords<{ Participant_ID: number; Contact_ID: number; @@ -601,7 +604,7 @@ export class FamilyService { { Contact_ID: contactId, Participant_Type_ID: participantTypeId, - Participant_Start_Date: new Date().toISOString(), + Participant_Start_Date: nowMpSql, } as { Participant_ID: number; Contact_ID: number; @@ -704,10 +707,12 @@ export class FamilyService { return { donorId: existingDonorId, envelopeNo: finalEnvelopeNo, bumped }; } + const tz = DomainTimezoneService.getInstance(); + const setupDateMpSql = await tz.toMpSqlDatetime(new Date()); const payload = { Contact_ID: contactId, Envelope_No: finalEnvelopeNo, - Setup_Date: new Date().toISOString(), + Setup_Date: setupDateMpSql, ...DONOR_DEFAULTS, }; const created = await this.mp!.createTableRecords<{ Donor_ID: number } & typeof payload>( diff --git a/src/services/groupService.test.ts b/src/services/groupService.test.ts index 50d53ec..f948fac 100644 --- a/src/services/groupService.test.ts +++ b/src/services/groupService.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock MPHelper — use vi.hoisted() per project convention -const { mockGetTableRecords, mockCreateTableRecords, mockUpdateTableRecords } = vi.hoisted(() => ({ +const { mockGetTableRecords, mockCreateTableRecords, mockUpdateTableRecords, mockGetDomainInfo } = vi.hoisted(() => ({ mockGetTableRecords: vi.fn(), mockCreateTableRecords: vi.fn(), mockUpdateTableRecords: vi.fn(), + mockGetDomainInfo: vi.fn(), })); vi.mock('@/lib/providers/ministry-platform', () => ({ @@ -12,16 +13,21 @@ vi.mock('@/lib/providers/ministry-platform', () => ({ getTableRecords = mockGetTableRecords; createTableRecords = mockCreateTableRecords; updateTableRecords = mockUpdateTableRecords; + getDomainInfo = mockGetDomainInfo; }, })); import { GroupService } from './groupService'; +import { DomainTimezoneService } from './domainTimezoneService'; describe('GroupService', () => { beforeEach(() => { vi.clearAllMocks(); - + mockGetDomainInfo.mockReset(); + mockGetDomainInfo.mockResolvedValue({ TimeZoneName: 'America/New_York' }); + (GroupService as any).instance = undefined; + (DomainTimezoneService as any).instance = null; }); describe('getInstance', () => { @@ -351,7 +357,7 @@ describe('GroupService', () => { 'Groups', [expect.objectContaining({ Group_Name: 'New Group', - Start_Date: '2024-03-01T00:00:00Z', // date-only converted to datetime + Start_Date: '2024-03-01 00:00:00', // date-only converted to MP-TZ SQL datetime End_Date: null, Promotion_Date: null, })], @@ -385,8 +391,8 @@ describe('GroupService', () => { [expect.objectContaining({ Group_ID: 100, Group_Name: 'Updated Group', - Start_Date: '2024-06-01T00:00:00Z', - End_Date: '2024-12-31T00:00:00Z', + Start_Date: '2024-06-01 00:00:00', + End_Date: '2024-12-31 00:00:00', Promotion_Date: null, })], { @@ -399,6 +405,24 @@ describe('GroupService', () => { }); }); + describe('date round-trip regression', () => { + it('round-tripping the same edit does not shift Start_Date', async () => { + // Reproduces the source-repo Contact_Log bug pattern: editing without + // changing the date field must not drift the saved value across + // successive saves, regardless of the server's local zone. + mockUpdateTableRecords.mockResolvedValue([{ Group_ID: 7, Group_Name: 'X' }]); + + const service = await GroupService.getInstance(); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); + + for (const call of mockUpdateTableRecords.mock.calls) { + expect((call[1][0] as { Start_Date: string }).Start_Date).toBe('2026-05-17 00:00:00'); + } + }); + }); + describe('error propagation', () => { it('should propagate errors from getTableRecords', async () => { mockGetTableRecords.mockRejectedValueOnce(new Error('API error')); diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 4e80c6a..d187124 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -1,5 +1,6 @@ import { MPHelper } from '@/lib/providers/ministry-platform'; import { escapeFilterString, validatePositiveInt } from '@/lib/validation'; +import { DomainTimezoneService } from '@/services/domainTimezoneService'; import type { GroupWizardLookups, ContactSearchResult, @@ -19,20 +20,18 @@ export interface GetGroupResult { displayNames: GroupWizardDisplayNames; } -/** Convert date-only strings (YYYY-MM-DD) to ISO datetime for the MP API */ -function toDatetime(value: string | null | undefined): string | null { - if (!value) return null; - if (value.includes('T')) return value; - return `${value}T00:00:00Z`; -} - -/** Prepare form data for the MP API by converting date fields to datetime */ -function prepareForApi(data: GroupWizardFormData): Record { +/** Prepare form data for the MP API by converting date fields to MP-TZ SQL datetime */ +async function prepareForApi( + data: GroupWizardFormData, +): Promise> { + const tz = DomainTimezoneService.getInstance(); + const convert = async (value: string | null | undefined) => + value ? await tz.toMpSqlDatetime(value) : null; return { ...data, - Start_Date: toDatetime(data.Start_Date), - End_Date: toDatetime(data.End_Date), - Promotion_Date: toDatetime(data.Promotion_Date), + Start_Date: await convert(data.Start_Date), + End_Date: await convert(data.End_Date), + Promotion_Date: await convert(data.Promotion_Date), }; } @@ -275,7 +274,7 @@ export class GroupService { data: GroupWizardFormData, userId: number, ): Promise<{ Group_ID: number; Group_Name: string }> { - const apiData = prepareForApi(data); + const apiData = await prepareForApi(data); const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', $userId: userId, @@ -288,7 +287,10 @@ export class GroupService { data: Partial, userId: number, ): Promise<{ Group_ID: number; Group_Name: string }> { - const apiData = { Group_ID: groupId, ...prepareForApi(data as GroupWizardFormData) }; + const apiData = { + Group_ID: groupId, + ...(await prepareForApi(data as GroupWizardFormData)), + }; const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name',