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
986 changes: 986 additions & 0 deletions .claude/playbooks/port-mp-datetime-handling.md

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ The setup wizard will:
5. Configure Ministry Platform API client credentials
6. Auto-generate `BETTER_AUTH_SECRET`
7. Install and update dependencies
8. Generate Ministry Platform types
9. Optionally generate the stored procedure reference
8. Regenerate Ministry Platform types against your MP instance
9. Generate the stored procedure reference (powers Claude Code suggestions)
10. Run a production build to verify configuration

When the wizard finishes it prints a reminder pointing to `_INSTALL/ministryplatform-install.sql` so you can deploy the SQL prerequisites to your MP database.
Expand Down Expand Up @@ -198,18 +198,28 @@ MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministrypl
| `NEXT_PUBLIC_APP_NAME` | Application display name. Default: `MPNextApp`. |
| `NEXT_PUBLIC_PROD_URL` | Production URL used by the Authorized Tools debug panel for path comparison. |

#### 3. Generate Ministry Platform Types
#### 3. Regenerate Ministry Platform Types

The repo ships with pre-generated models under `src/lib/providers/ministry-platform/models/` so a fresh clone can build immediately. Regenerate them against **your own** MP instance to pick up custom tables, columns, and constraints:

```bash
npm run mp:generate:models
```

This connects to your Ministry Platform API, fetches all table metadata, and generates:
This connects to your Ministry Platform API, fetches all table metadata, and regenerates:
- TypeScript interfaces for each table
- Zod v4 validation schemas for runtime validation
- Schema documentation at `.claude/references/ministryplatform.schema.md`
- Output to `src/lib/providers/ministry-platform/models/`

Run this any time your MP schema changes. The `--clean` flag in `npm run mp:generate:models` removes stale files before regenerating.

Optionally, also refresh the stored procedure reference (used by Claude Code for query suggestions):

```bash
npm run mp:generate:storedprocs
```

**Advanced options:**
```bash
# Generate types for specific tables only
Expand Down
14 changes: 10 additions & 4 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1200,12 +1200,18 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {
failedSteps++;
}

// Step 9: Stored procedure reference (optional)
printStepHeader(9, totalSteps, 'Generating stored procedure reference (optional)');
// Step 9: Stored procedure reference
printStepHeader(9, totalSteps, 'Generating stored procedure reference');
console.log(
chalk.gray(
' Produces .claude/references/ministryplatform.storedprocs.md — used by Claude Code'
)
);
console.log(chalk.gray(' for stored-proc query suggestions and authoring assistance.'));

const shouldGenerateStoredProcs = await confirm({
message: 'Generate stored procedure reference from Ministry Platform?',
default: false,
default: true,
});

if (shouldGenerateStoredProcs) {
Expand All @@ -1228,7 +1234,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {
passedSteps++;
}
} else {
console.log(chalk.gray(' Skipped'));
console.log(chalk.gray(' Skipped (run later with: npm run mp:generate:storedprocs)'));
passedSteps++;
}

Expand Down
20 changes: 10 additions & 10 deletions src/app/(web)/tools/groupwizard/group-wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ describe('GroupWizard shell', () => {

it('renders Step 1 after lookups resolve', async () => {
const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

await waitFor(() => {
expect(screen.getByTestId('step-identity')).toBeInTheDocument();
Expand All @@ -215,7 +215,7 @@ describe('GroupWizard shell', () => {

it('Next button does not advance past step 0 when required fields are empty', async () => {
const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

Expand All @@ -231,7 +231,7 @@ describe('GroupWizard shell', () => {

it('step click on a non-completed future step is ignored', async () => {
const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

Expand All @@ -247,7 +247,7 @@ describe('GroupWizard shell', () => {
it('shows the error Alert when fetchGroupRecord fails in edit mode', async () => {
mockFetchGroupRecord.mockResolvedValueOnce({ success: false, error: 'Group not found' });
const params: ToolParams = { recordID: 999 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

await waitFor(() => {
expect(screen.getByText('Group not found')).toBeInTheDocument();
Expand All @@ -268,7 +268,7 @@ describe('GroupWizard shell', () => {
});

const params: ToolParams = { recordID: 100 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

// Wait for lookups + record to resolve
await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
Expand All @@ -292,7 +292,7 @@ describe('GroupWizard shell', () => {

it('Cancel button invokes router.back', async () => {
const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);
await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

const cancelBtn = screen.getByRole('button', { name: /cancel/i });
Expand All @@ -317,7 +317,7 @@ describe('GroupWizard shell', () => {
});

const params: ToolParams = { recordID: 100 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);
await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

// Advance through steps 0 → 5 by clicking Next five times.
Expand Down Expand Up @@ -352,7 +352,7 @@ describe('GroupWizard shell', () => {
});

const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);
await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

// Use the test-only "Fill Valid" button on the mocked StepIdentity to
Expand Down Expand Up @@ -410,7 +410,7 @@ describe('GroupWizard shell', () => {
});

const params: ToolParams = { recordID: 100 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);
await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

for (let i = 0; i < 5; i++) {
Expand All @@ -433,7 +433,7 @@ describe('GroupWizard shell', () => {

it('exposes Cancel and Next controls on step 0', async () => {
const params: ToolParams = { recordID: -1 };
render(<GroupWizard params={params} />);
render(<GroupWizard params={params} mpTimezone="America/New_York" />);

await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());

Expand Down
17 changes: 11 additions & 6 deletions src/app/(web)/tools/groupwizard/group-wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
Expand All @@ -12,7 +12,7 @@ import { AlertCircle } from "lucide-react";
import { ToolParams, isNewRecord } from "@/lib/tool-params";
import {
groupWizardSchema,
GROUP_WIZARD_DEFAULTS,
buildGroupWizardDefaults,
STEP_FIELDS,
WIZARD_STEPS,
WizardStepper,
Expand All @@ -34,9 +34,10 @@ import {

interface GroupWizardProps {
params: ToolParams;
mpTimezone: string;
}

export function GroupWizard({ params }: GroupWizardProps) {
export function GroupWizard({ params, mpTimezone }: GroupWizardProps) {
const router = useRouter();
const isNew = isNewRecord(params);
const isEditMode = !isNew && !!params.recordID && params.recordID > 0;
Expand All @@ -51,9 +52,13 @@ export function GroupWizard({ params }: GroupWizardProps) {
const [contactDisplayMap, setContactDisplayMap] = useState<Map<number, string>>(new Map());
const [groupDisplayMap, setGroupDisplayMap] = useState<Map<number, string>>(new Map());

// Compute once per mount so "Create Another" re-renders pick up the current
// MP-TZ wall-clock day. (mpTimezone is stable per page render.)
const initialDefaults = useMemo(() => buildGroupWizardDefaults(mpTimezone), [mpTimezone]);

const form = useForm<GroupWizardFormData>({
resolver: zodResolver(groupWizardSchema),
defaultValues: GROUP_WIZARD_DEFAULTS,
defaultValues: initialDefaults,
mode: "onTouched",
});

Expand Down Expand Up @@ -155,12 +160,12 @@ export function GroupWizard({ params }: GroupWizardProps) {
}, [form, isEditMode, params.recordID]);

const handleCreateAnother = useCallback(() => {
form.reset(GROUP_WIZARD_DEFAULTS);
form.reset(buildGroupWizardDefaults(mpTimezone));
setCurrentStep(0);
setCompletedSteps(new Set());
setSubmitResult(null);
setLoadError(null);
}, [form]);
}, [form, mpTimezone]);

const handleClose = useCallback(() => {
router.back();
Expand Down
8 changes: 6 additions & 2 deletions src/app/(web)/tools/groupwizard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { GroupWizard } from "./group-wizard";
import { parseToolParams } from "@/lib/tool-params";
import { getMpTimezone } from "@/components/shared-actions/domain";

interface GroupWizardPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function GroupWizardPage({ searchParams }: GroupWizardPageProps) {
const params = await parseToolParams(await searchParams);
const [params, mpTimezone] = await Promise.all([
parseToolParams(await searchParams),
getMpTimezone(),
]);

return <GroupWizard params={params} />;
return <GroupWizard params={params} mpTimezone={mpTimezone} />;
}
7 changes: 6 additions & 1 deletion src/components/group-wizard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export { WizardStepper } from './wizard-stepper';
export { WizardNavigation } from './wizard-navigation';
export { ContactSearch } from './contact-search';
export { GroupSearch } from './group-search';
export { groupWizardSchema, GROUP_WIZARD_DEFAULTS, STEP_FIELDS } from './schema';
export {
groupWizardSchema,
GROUP_WIZARD_DEFAULTS,
buildGroupWizardDefaults,
STEP_FIELDS,
} from './schema';
export type { GroupWizardFormData } from './schema';
export { WIZARD_STEPS } from './types';
export type {
Expand Down
35 changes: 32 additions & 3 deletions src/components/group-wizard/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import {
groupWizardSchema,
GROUP_WIZARD_DEFAULTS,
buildGroupWizardDefaults,
STEP_FIELDS,
type GroupWizardFormData,
} from './schema';

/** Helper: fill all required IDs so schema passes base validation */
/** Helper: fill all required IDs + Start_Date so schema passes base validation */
function validBase(): GroupWizardFormData {
return {
...GROUP_WIZARD_DEFAULTS,
Expand All @@ -15,6 +16,7 @@ function validBase(): GroupWizardFormData {
Congregation_ID: 1,
Ministry_ID: 1,
Primary_Contact: 42,
Start_Date: '2026-01-15',
};
}

Expand Down Expand Up @@ -127,8 +129,8 @@ describe('STEP_FIELDS', () => {
});

describe('GROUP_WIZARD_DEFAULTS', () => {
it('has Start_Date pre-populated with today (YYYY-MM-DD)', () => {
expect(GROUP_WIZARD_DEFAULTS.Start_Date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
it('leaves Start_Date empty so the MP-TZ-aware factory must supply it', () => {
expect(GROUP_WIZARD_DEFAULTS.Start_Date).toBe('');
});

it('defaults all boolean flags to false', () => {
Expand All @@ -142,3 +144,30 @@ describe('GROUP_WIZARD_DEFAULTS', () => {
expect(GROUP_WIZARD_DEFAULTS.Available_On_App).toBeNull();
});
});

describe('buildGroupWizardDefaults', () => {
it('pre-populates Start_Date as YYYY-MM-DD', () => {
expect(buildGroupWizardDefaults('America/New_York').Start_Date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

it('formats today in the supplied MP time zone, not the runtime zone', () => {
// Pick an instant where the date differs between Pacific and UTC: 03:00 UTC
// is still the previous day in America/Los_Angeles. We can't override
// `new Date()` cleanly without faking timers, so instead assert that the
// pair (Etc/UTC vs America/Kiritimati, +14) produces dates that are never
// more than one day apart — proving the function actually consults the zone.
const utc = buildGroupWizardDefaults('Etc/UTC').Start_Date;
const kiritimati = buildGroupWizardDefaults('Pacific/Kiritimati').Start_Date;
const utcMs = Date.parse(`${utc}T00:00:00Z`);
const kirMs = Date.parse(`${kiritimati}T00:00:00Z`);
const diffDays = Math.abs(kirMs - utcMs) / 86_400_000;
expect(diffDays === 0 || diffDays === 1).toBe(true);
});

it('preserves all non-date defaults from GROUP_WIZARD_DEFAULTS', () => {
const built = buildGroupWizardDefaults('America/New_York');
expect(built.Meets_Online).toBe(GROUP_WIZARD_DEFAULTS.Meets_Online);
expect(built.Available_On_App).toBe(GROUP_WIZARD_DEFAULTS.Available_On_App);
expect(built.Description).toBe(GROUP_WIZARD_DEFAULTS.Description);
});
});
35 changes: 34 additions & 1 deletion src/components/group-wizard/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,30 @@ export const STEP_FIELDS: Record<number, (keyof GroupWizardFormData)[]> = {
5: [], // Review step — no fields to validate
};

/**
* Format today's date as YYYY-MM-DD in the MP domain's time zone. Callers
* thread the IANA zone in via `buildGroupWizardDefaults` — never reach back
* into `Intl` with the browser's local zone, since MP stores wall-clock
* values in the domain's TZ (not UTC). See
* .claude/references/ministryplatform.datetimehandling.md.
*/
function todayInMpTimezone(mpTimezone: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: mpTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date());
const lookup: Record<string, string> = {};
for (const part of parts) lookup[part.type] = part.value;
return `${lookup.year}-${lookup.month}-${lookup.day}`;
}

export const GROUP_WIZARD_DEFAULTS: GroupWizardFormData = {
Group_Name: '',
Group_Type_ID: undefined as unknown as number,
Description: null,
Start_Date: new Date().toISOString().split('T')[0],
Start_Date: '',
End_Date: null,
Reason_Ended: null,
Congregation_ID: undefined as unknown as number,
Expand Down Expand Up @@ -106,3 +125,17 @@ export const GROUP_WIZARD_DEFAULTS: GroupWizardFormData = {
Promotion_Date: null,
Descended_From: null,
};

/**
* Build form defaults with `Start_Date` pre-populated to today in the MP
* domain's time zone (IANA). Use this from the wizard component — the bare
* `GROUP_WIZARD_DEFAULTS` const leaves `Start_Date` empty because computing
* it at module-load time would use the browser/server's local zone instead
* of MP's.
*/
export function buildGroupWizardDefaults(mpTimezone: string): GroupWizardFormData {
return {
...GROUP_WIZARD_DEFAULTS,
Start_Date: todayInMpTimezone(mpTimezone),
};
}
9 changes: 7 additions & 2 deletions src/lib/providers/ministry-platform/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ const contacts = await mp.getTableRecords<Contact>({
select: 'Contact_ID,Display_Name,Email_Address'
});

// Create contact log
// Create contact log — route the datetime through DomainTimezoneService
// because MP stores wall-clock in the domain's TZ, not UTC.
// See .claude/references/ministryplatform.datetimehandling.md
import { DomainTimezoneService } from '@/services/domainTimezoneService';

const tz = DomainTimezoneService.getInstance();
await mp.createTableRecords('Contact_Log', [{
Contact_ID: 12345,
Contact_Date: new Date().toISOString(),
Contact_Date: await tz.toMpSqlDatetime(new Date()),
Made_By: 1,
Notes: 'Follow-up call completed'
}]);
Expand Down
Loading
Loading