Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .claude/references/ministryplatform.datetimehandling.md
Original file line number Diff line number Diff line change
@@ -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 (`<input type="date">`)

```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.
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions src/components/shared-actions/domain.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const tz = DomainTimezoneService.getInstance();
return tz.getMpTimezone();
}
154 changes: 154 additions & 0 deletions src/services/domainTimezoneService.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading