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.
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({
/>