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',