From 2124f0fb2def2789ef8cd4aef959065fda6913a3 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Thu, 21 May 2026 02:38:02 -0400 Subject: [PATCH 1/2] fix(contact-logs): correct timezone handling on Contact_Date save/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer reported Contact Log entries saving at the wrong date/time and each edit shifting the date back another day. Root cause was a double conversion: the form appended `T00:00:00.000Z` to the date string, and ContactLogService ran `new Date(...).getFullYear()` on the result — producing the SQL string in the Node server's local timezone, not the MP domain's. Editing re-applied the same transform to the already-shifted date. Ministry Platform stores datetimes as wall-clock values in the domain's configured time zone (exposed via `getDomainInfo().TimeZoneName`), not UTC. Changes: - Add DomainTimezoneService (`src/services/domainTimezoneService.ts`) — a singleton wrapping `MPHelper.getDomainInfo()` with cached IANA zone resolution. Includes a Windows→IANA mapping table since MP's API typically returns Windows zone names ("Eastern Standard Time") and `Intl` requires IANA ("America/New_York"). Public API: `getMpTimezone()`, `toMpSqlDatetime(value)`, `parseMpDatetime(value)`. - Add shared server action `getMpTimezone()` in `shared-actions/domain.ts` for client-side display. - Fix ContactLogService — `Contact_Date` now flows through `toMpSqlDatetime()`; Zod still validates the rest of the record. The `new Date(...).getFullYear()` round-trip is gone. - Fix `contact-logs.tsx` — stop appending UTC marker on submit; thread `mpTimezone` prop through page → ContactLookupDetails → ContactLogs; `formatDateTime` uses `Intl.DateTimeFormat({ timeZone })` so MP wall-clock values render correctly regardless of the user's browser zone. - Upgrade Add/Edit form to `` defaulting to current moment in MP-TZ wall-clock. Fresh "now" is seeded each time the Add Log dialog opens. Edit pre-fill preserves both date and time. - Add reference doc `.claude/references/ministryplatform.datetimehandling.md` covering why MP isn't UTC, the service API, write/read recipes, the anti-patterns this commit removes, and test mocking guidance. - Update `CLAUDE.md` with Key Development Practice #10 and a Reference Documents link so future MP date work picks this up. Tests: - 16 new tests for `DomainTimezoneService`, isolated via `mockReset()` and passing under both `TZ=UTC` and `TZ=America/Los_Angeles`. - Rewrote `contactLogService.test.ts` cases that previously asserted the broken Zod-rejection behavior; added a 3-edit round-trip regression that proves the date no longer drifts. - Full suite: 258/258 passing; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ministryplatform.datetimehandling.md | 166 ++++++++ CLAUDE.md | 2 + src/app/(web)/contactlookup/[guid]/page.tsx | 3 + src/components/contact-logs/contact-logs.tsx | 116 ++++-- .../contact-lookup-details.tsx | 3 + src/components/shared-actions/domain.ts | 16 + src/services/contactLogService.test.ts | 140 +++++-- src/services/contactLogService.ts | 82 ++-- src/services/domainTimezoneService.test.ts | 170 ++++++++ src/services/domainTimezoneService.ts | 373 ++++++++++++++++++ 10 files changed, 965 insertions(+), 106 deletions(-) create mode 100644 .claude/references/ministryplatform.datetimehandling.md create mode 100644 src/components/shared-actions/domain.ts create mode 100644 src/services/domainTimezoneService.test.ts create mode 100644 src/services/domainTimezoneService.ts diff --git a/.claude/references/ministryplatform.datetimehandling.md b/.claude/references/ministryplatform.datetimehandling.md new file mode 100644 index 00000000..d683916d --- /dev/null +++ b/.claude/references/ministryplatform.datetimehandling.md @@ -0,0 +1,166 @@ +# 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. Companion file: `ministryplatform.query-syntax.md` (for date filters in `$filter`). + +## 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. + +The contact-log timezone bug (2026-05-20) traced to two mistakes on the same path: the form appending `T00:00:00.000Z` to a date string, and the service running `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 (date diff, age calculation, comparison). For pure display, prefer `Intl.DateTimeFormat({ timeZone })` against the raw string — it's cheaper and avoids a round-trip through the cached domain info. + +## 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" +``` + +### Writing from a `` (user picks date + time in their browser) + +`datetime-local` emits values like `"2026-05-17T14:30"`. These are **browser-local wall-clock** by definition (no zone). If the user is in the MP timezone, treat as-is. If users may sit in a different zone than the MP domain, capture the browser's IANA zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`), submit it with the form, then on the server convert the wall-clock value through that zone first: + +```ts +// Treat the user-entered wall-clock as an instant in their zone, +// then re-format in MP-TZ. +const instant = new Date( + new Intl.DateTimeFormat("en-CA", { timeZone: browserZone /* ... */ }) /* ... */ +); +const mpDate = await tz.toMpSqlDatetime(instant); +``` + +(In practice we only have date-only inputs today. Revisit this when a datetime picker lands.) + +### 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]); +``` + +### Displaying a stored MP datetime in the browser + +`new Date(stringFromMp).toLocaleDateString(...)` parses the string as **browser-local**, which silently disagrees with MP-TZ for users sitting in a different zone. Format with an explicit `timeZone`: + +```tsx +function formatMpDateTime(value: string, mpTimezone: string): string { + // Build the UTC instant that, when rendered in mpTimezone, matches the + // stored wall-clock. See contact-logs.tsx for the helper. + // ... + 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 also 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 + +These caused or could have caused the contact-log bug. Grep for them when reviewing new code. + +| ❌ 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 via the table in `domainTimezoneService.ts`. 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. + +IANA names already containing `/` (e.g. test fixtures, some MP deployments) pass through unchanged. + +## Testing + +When a test exercises code that goes through `DomainTimezoneService`: + +1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — use `vi.hoisted()` because the singleton's `MPHelper` is constructed at module-load time (see CLAUDE.md testing notes). +2. **Reset the singleton** between tests: ``(DomainTimezoneService as any).instance = null`` in `beforeEach`. The service's internal cache otherwise carries the first test's zone into later tests. +3. **Use `mockReset()` (not `clearAllMocks()`)** on the `getDomainInfo` mock. `clearAllMocks` doesn't drain `mockResolvedValueOnce` queues, and tests that don't hit `getMpTimezone()` (date-only wall-clock paths) leave queue entries behind that leak forward. +4. **Run under multiple `TZ` env vars** for any logic that touches dates — 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. + +Example mock skeleton: + +```ts +const { mockGetDomainInfo } = vi.hoisted(() => ({ mockGetDomainInfo: vi.fn() })); + +vi.mock("@/lib/providers/ministry-platform", () => ({ + MPHelper: class { getDomainInfo = mockGetDomainInfo; }, +})); + +beforeEach(() => { + mockGetDomainInfo.mockReset(); + mockGetDomainInfo.mockResolvedValue({ TimeZoneName: "America/New_York" }); + (DomainTimezoneService as any).instance = null; +}); +``` diff --git a/CLAUDE.md b/CLAUDE.md index eac51faf..860e9d92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,7 @@ export default MyComponent; // ❌ Avoid 7. **Use TypeScript strict mode** - all code must be type-safe 8. **Validate at API boundaries** - use Zod schemas with the `schema` parameter in `createTableRecords()` and `updateTableRecords()` for runtime validation 9. **Use service classes in server actions** - call services from `src/services/`, not MPHelper directly from components or actions +10. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()` 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 @@ -226,4 +227,5 @@ For detailed context on specific areas, see: - **[Components Reference](.claude/references/components.md)** - Detailed inventory of all components, their purposes, server actions, and compliance status - **[Ministry Platform Schema](.claude/references/ministryplatform.schema.md)** - Auto-generated summary of Ministry Platform database tables, primary keys, and foreign key relationships - **[Ministry Platform Query Syntax](.claude/references/ministryplatform.query-syntax.md)** - SQL-style query syntax for `/tables/{table}/get` (filters, aggregates, `_TABLE` FK traversal rules, common errors and fixes) +- **[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 - **[Testing Reference](.claude/references/testing.md)** - Vitest setup, mock patterns (`vi.hoisted`, MPHelper, auth), coverage data, and test file inventory diff --git a/src/app/(web)/contactlookup/[guid]/page.tsx b/src/app/(web)/contactlookup/[guid]/page.tsx index 4b2dbafe..a6c41a19 100644 --- a/src/app/(web)/contactlookup/[guid]/page.tsx +++ b/src/app/(web)/contactlookup/[guid]/page.tsx @@ -4,6 +4,7 @@ import { getContactDetails, getContactLogsByContactId, } from "@/components/contact-lookup-details/actions"; +import { getMpTimezone } from "@/components/shared-actions/domain"; interface ContactLookupDetailPageProps { params: Promise<{ @@ -20,6 +21,7 @@ export default async function ContactLookupDetailPage({ const contactLogsPromise = contactPromise.then((c) => c.Contact_ID ? getContactLogsByContactId(c.Contact_ID) : [] ); + const mpTimezone = await getMpTimezone(); return (
@@ -36,6 +38,7 @@ export default async function ContactLookupDetailPage({
diff --git a/src/components/contact-logs/contact-logs.tsx b/src/components/contact-logs/contact-logs.tsx index 10deed69..58fb51fa 100644 --- a/src/components/contact-logs/contact-logs.tsx +++ b/src/components/contact-logs/contact-logs.tsx @@ -44,7 +44,7 @@ const ContactLogFormSchema = z.object({ .min(1, "Notes are required") .max(2000, "Notes must be less than 2000 characters"), contactLogType: z.string().optional(), - contactDate: z.string().min(1, "Contact date is required"), + contactDate: z.string().min(1, "Contact date and time is required"), contactId: z.number().min(1, "Contact ID is required"), }); @@ -55,26 +55,83 @@ interface ContactLogsProps { contactId: number; contactNickname?: string; contactLastName?: string; + mpTimezone: string; onRefresh?: () => void; } -function formatDateTime(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { +function formatDateTime(dateString: string, timeZone: string): string { + // MP returns wall-clock datetimes in its domain time zone (no zone marker). + // `new Date(...)` would parse those as browser-local — wrong for users in + // a different zone. Treat the string as MP-TZ wall-clock and format with + // Intl so the displayed value matches MP's database. + const normalized = dateString.replace("T", " ").split(".")[0]; + const match = normalized.match( + /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?(?:Z)?$/ + ); + let instant: Date; + if (match) { + const [, y, mo, d, h = "00", mi = "00", s = "00"] = match; + // Build the matching UTC instant by treating the wall-clock as MP-TZ. + const utcGuess = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const projectedParts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(new Date(utcGuess)); + const get = (t: string) => Number(projectedParts.find((p) => p.type === t)!.value); + const projectedHour = get("hour") === 24 ? 0 : get("hour"); + const projectedUtc = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + projectedHour, + get("minute"), + get("second") + ); + instant = new Date(utcGuess + (utcGuess - projectedUtc)); + } else { + instant = new Date(dateString); + } + return new Intl.DateTimeFormat("en-US", { + timeZone, month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", - }); + }).format(instant); +} + +function getNowInMpTz(timeZone: string): string { + // datetime-local input format: "YYYY-MM-DDTHH:MM" — render "now" as MP-TZ + // wall-clock so what the user sees matches what we'll store. + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(new Date()); + const get = (t: string) => parts.find((p) => p.type === t)!.value; + const hour = get("hour") === "24" ? "00" : get("hour"); + return `${get("year")}-${get("month")}-${get("day")}T${hour}:${get("minute")}`; } -function getTodayLocalDate(): string { - const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, "0"); - const day = String(today.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; +function toDatetimeLocalValue(mpDate: string): string { + // MP returns "YYYY-MM-DDTHH:MM:SS" or "YYYY-MM-DD HH:MM:SS" — trim seconds + // and any trailing fractional/Z portion to fit the datetime-local format. + const normalized = mpDate.replace(" ", "T"); + if (normalized.length >= 16) { + return normalized.slice(0, 16); + } + return `${normalized.slice(0, 10)}T00:00`; } export function ContactLogs({ @@ -82,6 +139,7 @@ export function ContactLogs({ contactId, contactNickname, contactLastName, + mpTimezone, onRefresh, }: ContactLogsProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -108,11 +166,20 @@ export function ContactLogs({ } = useForm({ resolver: zodResolver(ContactLogFormSchema), defaultValues: { - contactDate: getTodayLocalDate(), + contactDate: getNowInMpTz(mpTimezone), contactId: contactId, }, }); + const resetCreateForm = () => { + reset({ + contactDate: getNowInMpTz(mpTimezone), + contactId: contactId, + notes: "", + contactLogType: undefined, + }); + }; + const contactLogTypeValue = useWatch({ control, name: "contactLogType" }); const onCreateLog = async (data: ContactLogFormData) => { @@ -123,7 +190,7 @@ export function ContactLogs({ const contactLogData = { Contact_ID: data.contactId, - Contact_Date: `${data.contactDate}T00:00:00.000Z`, + Contact_Date: data.contactDate, Notes: data.notes, Contact_Log_Type_ID: selectedLogType?.Contact_Log_Type_ID || null, Planned_Contact_ID: null, @@ -135,14 +202,14 @@ export function ContactLogs({ console.log("Creating contact log with data:", contactLogData); await createContactLog(contactLogData); - + setIsCreateModalOpen(false); - reset(); - + resetCreateForm(); + if (onRefresh) { onRefresh(); } - + } catch (err) { console.error("Error creating contact log:", err); const errorMessage = err instanceof Error ? err.message : "Failed to create contact log"; @@ -161,7 +228,7 @@ export function ContactLogs({ const selectedLogType = logTypes.find(type => type.Contact_Log_Type === data.contactLogType); const contactLogData = { - Contact_Date: `${data.contactDate}T00:00:00.000Z`, + Contact_Date: data.contactDate, Notes: data.notes, Contact_Log_Type_ID: selectedLogType?.Contact_Log_Type_ID || null, }; @@ -192,7 +259,7 @@ export function ContactLogs({ setValue("contactLogType", log.Contact_Log_Type || ""); setValue( "contactDate", - log.Contact_Date ? log.Contact_Date.split("T")[0] : "" + log.Contact_Date ? toDatetimeLocalValue(log.Contact_Date) : "" ); setIsEditModalOpen(true); }; @@ -273,10 +340,10 @@ export function ContactLogs({ />
- + {errors.contactDate && ( @@ -341,10 +408,11 @@ export function ContactLogs({ if (isEdit) { setIsEditModalOpen(false); setEditingLog(null); + reset(); } else { setIsCreateModalOpen(false); + resetCreateForm(); } - reset(); }} > Cancel @@ -433,7 +501,7 @@ export function ContactLogs({ size="sm" className="flex items-center gap-2" variant="outline" - onClick={() => reset()} + onClick={() => resetCreateForm()} > Add Log @@ -487,7 +555,7 @@ export function ContactLogs({ {getDisplayLogType(log.Contact_Log_Type)} - {formatDateTime(log.Contact_Date)} + {formatDateTime(log.Contact_Date, mpTimezone)}
diff --git a/src/components/contact-lookup-details/contact-lookup-details.tsx b/src/components/contact-lookup-details/contact-lookup-details.tsx index 6dfd9995..41ecb4ff 100644 --- a/src/components/contact-lookup-details/contact-lookup-details.tsx +++ b/src/components/contact-lookup-details/contact-lookup-details.tsx @@ -12,11 +12,13 @@ import { ContactLogs } from "@/components/contact-logs"; interface ContactLookupDetailsProps { contactPromise: Promise; contactLogsPromise: Promise; + mpTimezone: string; } export const ContactLookupDetails: React.FC = ({ contactPromise, contactLogsPromise, + mpTimezone, }) => { const contact = use(contactPromise); const contactLogs = use(contactLogsPromise); @@ -166,6 +168,7 @@ export const ContactLookupDetails: React.FC = ({ contactId={contact.Contact_ID} contactNickname={contact.Nickname} contactLastName={contact.Last_Name} + mpTimezone={mpTimezone} onRefresh={() => router.refresh()} /> diff --git a/src/components/shared-actions/domain.ts b/src/components/shared-actions/domain.ts new file mode 100644 index 00000000..c75c1dc5 --- /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/contactLogService.test.ts b/src/services/contactLogService.test.ts index a088a8a9..6f8155f6 100644 --- a/src/services/contactLogService.test.ts +++ b/src/services/contactLogService.test.ts @@ -1,10 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ContactLogService } from '@/services/contactLogService'; -const mockGetTableRecords = vi.fn(); -const mockCreateTableRecords = vi.fn(); -const mockUpdateTableRecords = vi.fn(); -const mockDeleteTableRecords = vi.fn(); +const { + mockGetTableRecords, + mockCreateTableRecords, + mockUpdateTableRecords, + mockDeleteTableRecords, + mockGetDomainInfo, +} = vi.hoisted(() => ({ + mockGetTableRecords: vi.fn(), + mockCreateTableRecords: vi.fn(), + mockUpdateTableRecords: vi.fn(), + mockDeleteTableRecords: vi.fn(), + mockGetDomainInfo: vi.fn(), +})); vi.mock('@/lib/providers/ministry-platform', () => { return { @@ -13,15 +21,30 @@ vi.mock('@/lib/providers/ministry-platform', () => { createTableRecords = mockCreateTableRecords; updateTableRecords = mockUpdateTableRecords; deleteTableRecords = mockDeleteTableRecords; + getDomainInfo = mockGetDomainInfo; }, }; }); +import { ContactLogService } from '@/services/contactLogService'; +import { DomainTimezoneService } from '@/services/domainTimezoneService'; + describe('ContactLogService', () => { beforeEach(() => { - vi.clearAllMocks(); + mockGetTableRecords.mockReset(); + mockCreateTableRecords.mockReset(); + mockUpdateTableRecords.mockReset(); + mockDeleteTableRecords.mockReset(); + mockGetDomainInfo.mockReset(); + mockGetDomainInfo.mockResolvedValue({ + TimeZoneName: 'America/New_York', + DisplayName: 'Test', + CultureName: 'en-US', + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (ContactLogService as any).instance = undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (DomainTimezoneService as any).instance = null; }); describe('getInstance', () => { @@ -146,18 +169,14 @@ describe('ContactLogService', () => { }); describe('createContactLog', () => { - it('should convert ISO date to SQL format and validate', async () => { + it('passes a date-only Contact_Date through as MP-TZ midnight (no UTC shift)', async () => { const mockCreated = { Contact_Log_ID: 1, Contact_ID: 42 }; mockCreateTableRecords.mockResolvedValueOnce([mockCreated]); const service = await ContactLogService.getInstance(); - // Note: The service converts ISO to SQL format BEFORE Zod validation, - // but ContactLogSchema uses z.string().datetime() which expects ISO format. - // This means the validation will reject the converted SQL format. - // Testing with a non-datetime string to verify the conversion happens. - const inputData = { + const result = await service.createContactLog({ Contact_ID: 42, - Contact_Date: '2024-01-15T10:30:00.000Z', + Contact_Date: '2026-05-17', Contact_Log_Type_ID: 1, Made_By: 100, Notes: 'Test note', @@ -165,42 +184,67 @@ describe('ContactLogService', () => { Contact_Successful: null, Original_Contact_Log_Entry: null, Feedback_Entry_ID: null, - }; + }); - // The Zod validation will fail because the date is converted to SQL format - // before validation, and z.string().datetime() rejects SQL format - await expect(service.createContactLog(inputData)).rejects.toThrow(); + expect(mockCreateTableRecords).toHaveBeenCalledWith('Contact_Log', [ + expect.objectContaining({ + Contact_ID: 42, + Contact_Date: '2026-05-17 00:00:00', + Notes: 'Test note', + Made_By: 100, + }), + ]); + expect(result).toEqual(mockCreated); }); - it('should throw when API returns empty result', async () => { - mockCreateTableRecords.mockResolvedValueOnce([]); + it('converts a UTC-tagged Contact_Date into MP-TZ wall-clock', async () => { + mockCreateTableRecords.mockResolvedValueOnce([{ Contact_Log_ID: 1 }]); const service = await ContactLogService.getInstance(); - // Use data without Contact_Date to avoid the date conversion + validation issue - const inputData = { + // 2026-05-17T03:33:00Z = 2026-05-16 23:33:00 in America/New_York (EDT, UTC-4) + await service.createContactLog({ Contact_ID: 42, - Contact_Date: '2024-01-15 10:30:00', // Already SQL format to skip conversion + Contact_Date: '2026-05-17T03:33:00.000Z', Contact_Log_Type_ID: 1, Made_By: 100, - Notes: 'Test note', + Notes: 'Test', Planned_Contact_ID: null, Contact_Successful: null, Original_Contact_Log_Entry: null, Feedback_Entry_ID: null, - }; + }); + + expect(mockCreateTableRecords).toHaveBeenCalledWith('Contact_Log', [ + expect.objectContaining({ Contact_Date: '2026-05-16 23:33:00' }), + ]); + }); + + it('throws when API returns empty result', async () => { + mockCreateTableRecords.mockResolvedValueOnce([]); - // Still fails Zod validation since SQL format doesn't pass z.string().datetime() - await expect(service.createContactLog(inputData)).rejects.toThrow(); + const service = await ContactLogService.getInstance(); + await expect( + service.createContactLog({ + Contact_ID: 42, + Contact_Date: '2026-05-17', + Contact_Log_Type_ID: 1, + Made_By: 100, + Notes: 'Test', + Planned_Contact_ID: null, + Contact_Successful: null, + Original_Contact_Log_Entry: null, + Feedback_Entry_ID: null, + }) + ).rejects.toThrow('Failed to create contact log record'); }); - it('should reject invalid data via Zod validation', async () => { + it('rejects invalid non-date fields via Zod validation', async () => { const service = await ContactLogService.getInstance(); - // Missing required fields should fail validation await expect( service.createContactLog({ Contact_ID: 42, - Contact_Date: '2024-01-15T10:30:00Z', + Contact_Date: '2026-05-17', Contact_Log_Type_ID: null, // Missing Made_By (required number) Notes: 'Test', @@ -210,12 +254,11 @@ describe('ContactLogService', () => { }); describe('updateContactLog', () => { - it('should update with partial validation and add Contact_Log_ID', async () => { + it('updates non-date fields and adds Contact_Log_ID', async () => { const mockUpdated = { Contact_Log_ID: 1, Notes: 'Updated note' }; mockUpdateTableRecords.mockResolvedValueOnce([mockUpdated]); const service = await ContactLogService.getInstance(); - // Partial updates without dates should pass partial validation const result = await service.updateContactLog(1, { Notes: 'Updated note' }); expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contact_Log', [ @@ -227,17 +270,38 @@ describe('ContactLogService', () => { expect(result).toEqual(mockUpdated); }); - it('should convert ISO date to SQL format on update', async () => { + it('converts a date-only Contact_Date to MP-TZ midnight on update', async () => { + mockUpdateTableRecords.mockResolvedValueOnce([{ Contact_Log_ID: 1 }]); + const service = await ContactLogService.getInstance(); + await service.updateContactLog(1, { Contact_Date: '2026-05-17' }); - // Date conversion happens, then partial Zod validation runs. - // Partial validation with z.string().datetime() still rejects SQL format. - await expect( - service.updateContactLog(1, { Contact_Date: '2024-06-15T14:00:00.000Z' }) - ).rejects.toThrow(); + expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contact_Log', [ + expect.objectContaining({ + Contact_Log_ID: 1, + Contact_Date: '2026-05-17 00:00:00', + }), + ]); + }); + + it('regression: round-tripping the same edit does not shift the date', async () => { + // The original bug: editing a saved log moved its date back another day + // each time. After the fix, the date the user reads back (from MP, in + // MP wall-clock) should round-trip unchanged when re-saved. + mockUpdateTableRecords.mockResolvedValue([{ Contact_Log_ID: 1 }]); + + const service = await ContactLogService.getInstance(); + // Form pre-fills from log.Contact_Date.split("T")[0] — date-only string. + await service.updateContactLog(1, { Contact_Date: '2026-05-17' }); + await service.updateContactLog(1, { Contact_Date: '2026-05-17' }); + await service.updateContactLog(1, { Contact_Date: '2026-05-17' }); + + for (const call of mockUpdateTableRecords.mock.calls) { + expect(call[1][0].Contact_Date).toBe('2026-05-17 00:00:00'); + } }); - it('should throw when API returns empty result', async () => { + it('throws when API returns empty result', async () => { mockUpdateTableRecords.mockResolvedValueOnce([]); const service = await ContactLogService.getInstance(); diff --git a/src/services/contactLogService.ts b/src/services/contactLogService.ts index a049d49b..de39d8e4 100644 --- a/src/services/contactLogService.ts +++ b/src/services/contactLogService.ts @@ -2,6 +2,7 @@ import { ContactLog } from "@/lib/providers/ministry-platform/models/ContactLog" import { ContactLogTypes } from "@/lib/providers/ministry-platform/models/ContactLogTypes"; import { ContactLogSchema, ContactLogInput } from "@/lib/providers/ministry-platform/models/ContactLogSchema"; import { MPHelper } from "@/lib/providers/ministry-platform"; +import { DomainTimezoneService } from "@/services/domainTimezoneService"; /** * ContactLogService - Singleton service for managing contact log operations @@ -132,35 +133,28 @@ export class ContactLogService { contactLogData: Omit, ): Promise { console.log('ContactLogService.createContactLog - Creating with data:', JSON.stringify(contactLogData, null, 2)); - - // Convert ISO date to SQL Server format (YYYY-MM-DD HH:MM:SS) - // Ministry Platform expects SQL datetime format, not ISO format - if (contactLogData.Contact_Date) { - const date = new Date(contactLogData.Contact_Date); - // Format as SQL Server datetime - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - contactLogData.Contact_Date = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; - console.log('ContactLogService.createContactLog - Converted date to SQL format:', contactLogData.Contact_Date); - } - - // Validate the input data (update schema to accept SQL datetime string) - const validatedData = ContactLogSchema.omit({ Contact_Log_ID: true }).parse(contactLogData); - console.log('ContactLogService.createContactLog - Validation successful'); - + + // Validate non-date fields with the generated schema; Contact_Date is + // handled separately by DomainTimezoneService since the generated schema + // expects ISO and MP needs SQL wall-clock in the domain time zone. + const { Contact_Date, ...rest } = contactLogData; + const validatedRest = ContactLogSchema + .omit({ Contact_Log_ID: true, Contact_Date: true }) + .parse(rest); + + const tz = DomainTimezoneService.getInstance(); + const mpDate = await tz.toMpSqlDatetime(Contact_Date); + console.log('ContactLogService.createContactLog - MP-TZ SQL date:', mpDate); + const result = await this.mp!.createTableRecords( "Contact_Log", - [validatedData] + [{ ...validatedRest, Contact_Date: mpDate }] ); - + if (!result || result.length === 0) { throw new Error('Failed to create contact log record'); } - + return result[0] as ContactLog; } @@ -177,35 +171,35 @@ export class ContactLogService { ): Promise { console.log('ContactLogService.updateContactLog - Updating log:', contactLogId); console.log('ContactLogService.updateContactLog - Update data:', JSON.stringify(contactLogData, null, 2)); - - // Convert ISO date to SQL Server format if provided - if (contactLogData.Contact_Date) { - const date = new Date(contactLogData.Contact_Date); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - contactLogData.Contact_Date = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; - console.log('ContactLogService.updateContactLog - Converted date to SQL format:', contactLogData.Contact_Date); + + const { Contact_Date, ...rest } = contactLogData; + const validatedRest = ContactLogSchema + .omit({ Contact_Log_ID: true, Contact_Date: true }) + .partial() + .parse(rest); + + let mpDate: string | undefined; + if (Contact_Date !== undefined && Contact_Date !== null) { + const tz = DomainTimezoneService.getInstance(); + mpDate = await tz.toMpSqlDatetime(Contact_Date); + console.log('ContactLogService.updateContactLog - MP-TZ SQL date:', mpDate); } - - // Validate the input data - const validatedData = ContactLogSchema.omit({ Contact_Log_ID: true }).partial().parse(contactLogData); - - // Add the ID to the data for the update - const updateData = { Contact_Log_ID: contactLogId, ...validatedData }; - + + const updateData = { + Contact_Log_ID: contactLogId, + ...validatedRest, + ...(mpDate !== undefined ? { Contact_Date: mpDate } : {}), + }; + const result = await this.mp!.updateTableRecords( "Contact_Log", [updateData] ); - + if (!result || result.length === 0) { throw new Error('Failed to update contact log record'); } - + return result[0] as ContactLog; } diff --git a/src/services/domainTimezoneService.test.ts b/src/services/domainTimezoneService.test.ts new file mode 100644 index 00000000..4d22bbb4 --- /dev/null +++ b/src/services/domainTimezoneService.test.ts @@ -0,0 +1,170 @@ +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 { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (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(() => { + 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", + DisplayName: "Test", + CultureName: "en-US", + }); + 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 () => { + // 2026-05-17T03:33:00Z = 2026-05-16 23:33:00 in America/New_York (EDT, UTC-4) + 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"); + // 2026-05-16 20:33:00 in America/Los_Angeles (PDT, UTC-7) + 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 () => { + // Simulates the original bug: a US/Eastern server processing a date the + // user picked in their browser. The fix is to NOT apply UTC conversion + // to date-only inputs — they're wall-clock by intent. + 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(); + // 2026-05-17 12:00:00 in America/New_York (EDT, UTC-4) = 2026-05-17T16:00:00Z + 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 00000000..65abf604 --- /dev/null +++ b/src/services/domainTimezoneService.ts @@ -0,0 +1,373 @@ +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"; + } + // IANA names contain a "/" (e.g. America/New_York, Etc/GMT+5). + 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; +} + +/** + * Parses an MP wall-clock string into its calendar parts. Accepts: + * - "YYYY-MM-DD" + * - "YYYY-MM-DD HH:MM[:SS]" + * - "YYYY-MM-DDTHH:MM[:SS][.fff]" (with or without trailing Z) + * + * Strings ending in "Z" or carrying an explicit ±HH:MM offset are returned as + * `null` so callers know to parse them as `Date` instants rather than wall-clock. + */ +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), + }; +} + +/** + * Formats a `Date` instant as a wall-clock SQL datetime string in the supplied + * IANA time zone. Output: "YYYY-MM-DD HH:MM:SS". Used when converting a known + * UTC instant into the value MP's database expects. + */ +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 (form + * inputs, server actions, display formatters). + * + * 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. See + * `.claude/references/ministryplatform.datetimehandling.md` for the full + * recipe and the anti-patterns this service replaces. + */ +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; + } + + /** + * Returns the IANA time zone identifier for the current MP domain. Result is + * cached for the lifetime of the process — call `clearCache()` if a domain + * configuration change needs to take effect without a restart. + */ + 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). + * + * Input handling: + * - Wall-clock string with no zone marker (e.g. "2026-05-17", + * "2026-05-17 14:30:00", "2026-05-17T14:30") is treated as MP-TZ + * wall-clock and reformatted — missing components default to zero. + * - String with trailing "Z" or "±HH:MM" offset is parsed as a UTC + * instant and converted into MP-TZ wall-clock. + * - `Date` instances are 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)}`; + } + // Falls through to instant interpretation (Z or explicit offset). + 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 to do 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) { + // Has an explicit zone marker — parse as instant directly. + 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(); + // Build a candidate UTC instant from the wall-clock fields, then correct + // by the offset between that instant's UTC and MP-TZ representations. + 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(); From b358a5b7f3214d6c301d132bcc7437c68f045d20 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Thu, 21 May 2026 02:39:22 -0400 Subject: [PATCH 2/2] chore(todo): remove resolved contact-log timezone investigation note Issue is fixed and documented; the TODO's contents are now captured in the commit that landed the fix and in `.claude/references/ministryplatform.datetimehandling.md`. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...vestigate-contact-log-date-timezone-bug.md | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 .claude/TODO/investigate-contact-log-date-timezone-bug.md diff --git a/.claude/TODO/investigate-contact-log-date-timezone-bug.md b/.claude/TODO/investigate-contact-log-date-timezone-bug.md deleted file mode 100644 index 336a6638..00000000 --- a/.claude/TODO/investigate-contact-log-date-timezone-bug.md +++ /dev/null @@ -1,43 +0,0 @@ -# TODO: Contact Log entries save with wrong date/time (timezone bug) - -**Created:** 2026-05-21 -**Severity:** Real customer-facing data bug — entries appear on the wrong day, and editing makes it worse. -**Reported by:** Customer feedback on `/contactlookup` demo app, 2026-05-20. - -## Symptom - -- Customer created a Contact Log entry at approximately **11:33 PM on 2026-05-17**. -- The saved record displayed as **2026-05-16 at 8:00 PM** — wrong by ~27.5 hours. -- **Editing** the same entry shifted it back an **additional day** (so the displayed date moved further into the past with each edit). - -The pattern (off by a day + edits compound the drift) strongly suggests a double UTC conversion: a value that is already UTC is being treated as local time and re-converted, and the edit path re-runs the same conversion on the already-shifted value. - -## Where to look - -Likely suspects, in order: - -1. **`src/components/contact-logs/contact-logs.tsx`** — date input handling. Check whether the date is being read as a local-time `Date` and then passed through `.toISOString()` (which subtracts the local offset). On a US time zone at 11:33 PM, `toISOString()` of a date constructed as `new Date('2026-05-17')` (midnight local) would yield `2026-05-17T05:00:00Z` ish — but if instead a `Date` object is being built from already-UTC strings and `.toISOString()`'d again, the offset doubles. -2. **`src/components/contact-logs/actions.ts`** — server action that calls the service. Verify what string format is being sent to MP. -3. **`src/services/contactLogService.ts`** — the service wrapper. Check if any normalization happens here. -4. **Edit flow** — separately verify whether the edit form initializes from the stored value (already UTC) by treating it as local. That would explain why **each edit shifts another day**. - -## Things to check - -- What does the `Contact_Date` field expect on the MP side? (MP typically stores datetimes in the instance's configured time zone, not UTC — this is a common source of mismatch.) -- Is there a `new Date(stringFromMP)` happening anywhere on the read path that's being re-serialized with `.toISOString()` on the write path? -- Does the date input component use `` (local) vs `` (deprecated) vs a library that has its own zone handling? -- Does any code path call `.toISOString()` on a value that already came back as ISO from MP? - -## How to reproduce - -1. Sign in to the demo app. -2. Look up a contact via `/contactlookup`. -3. Open Contact Logs for that contact. -4. Add a new log entry late in the day (e.g., after 10 PM local). -5. Save and observe the displayed date/time vs what was entered. -6. Edit the saved entry without changing any fields, save again, and observe whether the date drifts further. - -## Out of scope / related - -- The **`Feedback_Entry_ID` error in Contact Lookup search** is a known, separate issue (discussed in Office Hours) — not this TODO. -- Once fixed, add a regression test in `src/components/contact-logs/` that creates a log at a fixed instant and asserts round-trip equality.