diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 50cc1e3..f69ff70 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,270 +1,137 @@ # Release Command -Create a release pull request from `dev` to `main` with auto-generated release notes summarizing all changes since the last release. +Create a GitHub release with auto-generated release notes from merged pull requests. ## Instructions -1. **Validate prerequisites:** - - Ensure `gh` CLI is installed and authenticated - - Ensure you're on the `dev` branch (or offer to switch) - - Check for uncommitted changes and handle them - -2. **Determine the last release point:** - - Look for the most recent git tag on `main` (e.g., `v2026.1.15.1430`) - - If no tags exist, find the last merge commit to `main` - - If neither exists, use the initial commit - - Display the last release reference to the user - -3. **Gather all changes since last release:** - - Get all merged PRs to `dev` since the last release: - ``` - gh pr list --base dev --state merged --json number,title,body,mergedAt,author,labels --limit 500 - ``` - - Filter PRs merged after the last release date - - Extract GitHub issue references from PR titles and bodies (patterns: `#123`, `Fix #123`, `Closes #123`, `Resolves #123`) - -4. **Fetch issue details:** - - For each referenced issue, fetch details: - ``` - gh issue view --json number,title,labels,state - ``` - - Categorize issues by labels (bug, feature, enhancement, documentation, etc.) - -5. **Generate version number:** - - Automatically compute version using the project's date-time format: `yyyy.m.d.hhmm` - - Use current UTC time: - - `yyyy` = full year (e.g., 2026) - - `m` = month without leading zero (1-12) - - `d` = day without leading zero (1-31) - - `hhmm` = hours and minutes in UTC with leading zeros (e.g., 0930, 1445) - - Example: `2026.1.23.1430` for January 23, 2026 at 14:30 UTC - - This matches the build version format used in `next.config.js` and displayed in the footer - - No user confirmation needed - version is deterministic based on release time - -6. **Write version to VERSION file:** - - Write the computed version (without `v` prefix) to the `VERSION` file at repo root - - This ensures the deployed app shows the exact same version as the release tag - - Stage and commit: `git add VERSION && git commit -m "chore: set release version "` - - Push to dev: `git push origin dev` - - This commit becomes part of the release PR (dev → main) - -7. **Generate release notes:** - - Create human-friendly descriptions for each change - - Group by category (see Release Notes Format below) - - Include links to PRs and issues - - Summarize contributor activity - -8. **Check for existing release PR:** - - Run `gh pr list --base main --head dev --state open --json number,title,url` - - If exists, offer to update it or abort - -9. **Create the release PR:** - - Use `gh pr create --base main --head dev --title "" --body "<body>"` - - Title format: `Release <version>` or `Release <version>: <summary>` - -10. **Post-creation:** - - Display the PR URL - - Show the version number that was used - - Display a formatted box with next steps: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Release PR Created Successfully! │ -│ │ -│ Version: v2026.1.23.1430 │ -│ PR: https://github.com/org/repo/pull/XXX │ -│ │ -│ Next Steps: │ -│ 1. Review and approve the PR │ -│ 2. Merge the PR to main │ -│ 3. Run the finish command to create the tag and release: │ -│ │ -│ /release-finish v2026.1.23.1430 │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` +1. **Gather context:** + - Run `gh release list --limit 5` to find the most recent release (if any) + - Run `git tag --sort=-version:refname | head -5` to see existing tags + - If a previous release exists, identify its tag to scope the changelog + - Run `git log --oneline` (from last release tag to HEAD, or recent commits if first release) to understand what's new + +2. **Identify PRs to include:** + - If this is the first release, run `gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels` to get recent merged PRs + - If a previous release exists, find PRs merged since that release using `gh pr list --state merged --search "merged:>YYYY-MM-DD" --json number,title,mergedAt,body,labels` + - Present the list of PRs to the user and ask which to include (default: all) + +3. **Determine version:** + - Auto-compute the version tag using calver format: `v{YYYY}.{MM}.{DD}.{HHmm}` based on the current date and time + - Generate it with: `date -u +v%Y.%m.%d.%H%M` (UTC time) + - Example: `v2026.02.20.1735` means 2026-02-20 at 17:35 UTC + - Show the computed version to the user for confirmation + - If `--tag` argument was provided, use that instead of auto-computing + - If the computed tag already exists, append a `.1` suffix (e.g., `v2026.02.20.1735.1`) + +4. **Generate release notes:** + - Categorize included PRs by type using PR title prefixes and content: + - `⚠️ Breaking Changes` — any PR with breaking changes (migration steps, renamed APIs, changed URLs, removed features) + - `🚀 Features` — PRs with `feat:` prefix or feature work + - `🐛 Bug Fixes` — PRs with `fix:` prefix or bug fixes + - `📚 Documentation` — PRs with `docs:` prefix or doc-only changes + - `🔧 Maintenance` — dependency updates, refactoring, CI changes, chores + - For each PR, write a concise summary line: `**Short title** (#number) — One-sentence description.` + - Pull the description from the PR body's Summary section if available + - Only include categories that have PRs in them + - Ask the user if there are any breaking changes or additional notes to add + +5. **Review with user:** + - Show the complete draft release notes to the user + - Ask if any edits are needed before publishing + - Apply any requested changes + +6. **Create the release:** + - Run `gh release create <tag> --target main --title "<tag>" --notes "<notes>"` + - Use a HEREDOC for the notes body to handle multiline content + - Run `git fetch --tags` to sync the new tag locally + +7. **Post-creation:** + - Display the release URL + - Confirm tag is synced locally + - Show summary of what was released ## Arguments - `$ARGUMENTS` - Optional arguments: - - `--draft` - Create as draft PR - - `--dry-run` - Show what would be included without creating PR - - `--since <ref>` - Override the last release point (tag, commit, or date) - -## Version Number Computation - -The version is automatically computed at release time using this JavaScript logic (matching `next.config.js`): - -```javascript -const now = new Date(); -const year = now.getUTCFullYear(); -const month = now.getUTCMonth() + 1; // No leading zero -const day = now.getUTCDate(); // No leading zero -const hours = now.getUTCHours().toString().padStart(2, '0'); -const minutes = now.getUTCMinutes().toString().padStart(2, '0'); -const version = `${year}.${month}.${day}.${hours}${minutes}`; -// Result: "2026.1.23.1430" -``` + - `--tag <version>` - Specify version tag directly (skip version prompt) + - `--draft` - Create as draft release + - `--prerelease` - Mark as pre-release + - `--target <branch>` - Target branch (default: `main`) + - `--since <tag>` - Override: include PRs since this tag instead of auto-detecting ## Release Notes Format ```markdown -# Release v<version> - -> Released on <date> - -## Summary - -<Brief 1-2 sentence overview of this release> - ## What's Changed -### Bug Fixes -- <Human-friendly description of fix> ([#123](link-to-pr)) - Fixes [#456](link-to-issue) -- <Another fix description> ([#124](link-to-pr)) +### ⚠️ Breaking Changes + +- **Description of breaking change** (#PR) — What changed and what action users must take. -### New Features -- <Human-friendly description of feature> ([#125](link-to-pr)) - Implements [#457](link-to-issue) +### 🚀 Features -### Improvements -- <Human-friendly description of improvement> ([#126](link-to-pr)) +- **Feature title** (#PR) — Brief description of the feature. -### Documentation -- <Documentation update description> ([#127](link-to-pr)) +### 🐛 Bug Fixes -### Other Changes -- <Other change description> ([#128](link-to-pr)) +- **Fix title** (#PR) — Brief description of what was fixed. -## Contributors +### 📚 Documentation -Thanks to the following contributors for this release: -- @username1 -- @username2 +- **Doc change title** (#PR) — Brief description of doc changes. ---- +### 🔧 Maintenance -**Full Changelog**: <link-to-compare-view> +- **Maintenance title** (#PR) — Brief description of maintenance work. -Generated with [Claude Code](https://claude.ai/code) +**Full Changelog**: https://github.com/OWNER/REPO/compare/PREVIOUS_TAG...NEW_TAG ``` ## Example Workflow ```bash -# 1. Ensure on dev branch -git branch --show-current - -# 2. Get the latest tag on main -git describe --tags --abbrev=0 origin/main 2>/dev/null || echo "No tags found" - -# 3. Get the date of the last release -git log -1 --format=%ci <last-tag> 2>/dev/null - -# 4. Get merged PRs since last release -gh pr list --base dev --state merged --json number,title,body,mergedAt,author,labels --limit 100 - -# 5. Get issue details for referenced issues -gh issue view 123 --json number,title,labels,state - -# 6. Check for existing release PR -gh pr list --base main --head dev --state open --json number,title,url +# 1. Check existing releases and tags +gh release list --limit 5 +git tag --sort=-version:refname | head -5 -# 7. Compute version number (UTC time) -# JavaScript equivalent: new Date() -> "2026.1.23.1430" -# In bash, compute as: date -u +"%Y.%-m.%-d.%H%M" +# 2. Get merged PRs since last release +gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels -# 8. Write version to VERSION file and commit -echo "2026.1.23.1430" > VERSION -git add VERSION && git commit -m "chore: set release version 2026.1.23.1430" -git push origin dev - -# 9. Create the release PR -gh pr create --base main --head dev --title "Release v2026.1.23.1430" --body "$(cat <<'EOF' -# Release v2026.1.23.1430 - -> Released on January 23, 2026 - -## Summary - -This release includes 5 bug fixes and 3 new features. +# 3. Get repo info for changelog URL +gh repo view --json nameWithOwner --jq .nameWithOwner +# 4. Create the release +gh release create v2026.02.20.1735 --target main --title "v2026.02.20.1735" --notes "$(cat <<'EOF' ## What's Changed -### Bug Fixes -- Fix bank name not displaying correctly on deposit list ([#800](https://github.com/org/repo/pull/800)) - Fixes [#779](https://github.com/org/repo/issues/779) +### 🚀 Features -### New Features -- Add email notification for customer file uploads ([#799](https://github.com/org/repo/pull/799)) - Implements [#55](https://github.com/org/repo/issues/55) +- **New feature** (#44) — Description of the feature. -## Contributors +### 🐛 Bug Fixes -Thanks to the following contributors: -- @developer1 +- **Bug fix** (#40) — Description of the fix. ---- - -**Full Changelog**: https://github.com/org/repo/compare/v2026.1.15.0930...dev - -Generated with [Claude Code](https://claude.ai/code) +**Full Changelog**: https://github.com/owner/repo/compare/v0.1.0...v2026.02.20.1735 EOF )" -# 10. After PR is merged, create the tag -git checkout main -git pull origin main -git tag -a v2026.1.23.1430 -m "Release v2026.1.23.1430" -git push origin v2026.1.23.1430 - -# 11. Optionally create GitHub Release -gh release create v2026.1.23.1430 --title "v2026.1.23.1430" --notes-file release-notes.md +# 5. Sync tag locally +git fetch --tags ``` -## Categorization Rules - -Categorize changes based on PR/issue labels and title patterns: - -| Category | Labels | Title Patterns | -|----------------|-------------------------------------|------------------------------------| -| Bug Fixes | `bug`, `fix`, `bugfix` | `fix`, `bugfix`, `hotfix` | -| New Features | `feature`, `enhancement` | `add`, `feature`, `implement` | -| Improvements | `improvement`, `refactor`, `perf` | `improve`, `refactor`, `optimize` | -| Documentation | `documentation`, `docs` | `doc`, `readme` | -| Dependencies | `dependencies`, `deps` | `bump`, `upgrade`, `update deps` | -| Other | (none of the above) | (none of the above) | - -## Human-Friendly Description Guidelines - -When generating descriptions: - -1. **Start with a verb** - "Fix", "Add", "Update", "Improve", "Remove" -2. **Be specific** - Mention the affected feature or component -3. **Focus on user impact** - What can users now do? What problem is solved? -4. **Keep it concise** - One line, ideally under 80 characters - -**Examples:** -- PR title: "Fix #779: Bank name not displaying correctly on deposit list" - - Human-friendly: "Fix bank name not displaying correctly in deposit list view" -- PR title: "Add email notification for customer file uploads #55" - - Human-friendly: "Add email notifications when customers upload files" -- PR title: "Refactor transaction service for better performance" - - Human-friendly: "Improve transaction loading performance" - ## Error Handling -- If `gh` CLI is not installed or not authenticated, provide setup instructions -- If no changes found since last release, inform user and abort -- If dev branch is behind main, warn user and suggest rebasing -- If PR creation fails, show the error and suggest manual steps -- Always show what would be included before creating the PR +- If `gh` CLI is not installed or not authenticated, provide instructions for setup +- If no merged PRs are found since the last release, inform the user and ask how to proceed +- If the tag already exists, warn the user and ask if they want to use a different tag +- If release creation fails, show the error and suggest fixes ## Notes -- Always sync dev with latest changes before creating release PR: `git pull origin dev` -- The release PR should be reviewed carefully before merging -- After merging, remember to create the git tag on main -- Consider using GitHub Releases for better visibility -- Include "Generated with [Claude Code](https://claude.ai/code)" in PR body -- Use HEREDOC for PR body to handle multiline content and special characters -- Version number is automatically computed using UTC time - no manual input needed -- The version format (`yyyy.m.d.hhmm`) matches the build version shown in the app footer -- The VERSION file at repo root is written with each release and read by `next.config.js` during Vercel builds to ensure the deployed app shows the exact release version +- Always use HEREDOC for release notes to handle multiline content and special characters +- Include the Full Changelog comparison link at the bottom when a previous release exists +- Default target branch is `main` — confirm with user if the repo uses a different default +- Sync tags locally after creating the release so `git describe` and local tooling work correctly +- When categorizing PRs, prefer using the PR title prefix (feat:, fix:, docs:, chore:) but fall back to analyzing the PR body content +- Ask about breaking changes explicitly — they're easy to miss but critical for users upgrading diff --git a/.claude/playbooks/port-mp-datetime-handling.md b/.claude/playbooks/port-mp-datetime-handling.md new file mode 100644 index 0000000..e50aa1e --- /dev/null +++ b/.claude/playbooks/port-mp-datetime-handling.md @@ -0,0 +1,986 @@ +# Playbook: Port Ministry Platform Date/Time Handling Into This Repo + +You are Claude Code running in a repo that integrates with **Ministry Platform** (MP). Another team has solved a class of bugs where MP datetimes round-trip through UTC and silently drift by the server's-local-to-MP-timezone offset. This playbook ports that fix into the repo you're in. + +**Outcome you're driving toward** + +1. A `DomainTimezoneService` singleton exists in this repo and is the only path through which code reads `MPHelper.getDomainInfo().TimeZoneName`. +2. Every MP datetime write goes through `DomainTimezoneService.toMpSqlDatetime(...)`. No more `Date.toISOString()` / `T00:00:00.000Z` / `new Date(...).getFullYear()` patterns on the MP boundary. +3. Every MP datetime display uses `Intl.DateTimeFormat({ timeZone })` with the MP timezone, not browser-local parsing. +4. A reference doc `.claude/references/ministryplatform.datetimehandling.md` is checked in. +5. `CLAUDE.md` cites the reference and includes a Key Development Practice rule pointing to it. +6. Tests cover the service and at least one round-trip regression for a real datetime field, and pass under `TZ=UTC` and `TZ=America/Los_Angeles`. + +**Do not skip the discovery phase.** Paths, naming, and the exact MPHelper surface may differ from the source project. Confirm the assumptions before writing code. + +## Background — the bug class you're preventing + +MP stores datetimes as **wall-clock values in the domain's configured time zone** (exposed via `getDomainInfo().TimeZoneName`). It does **not** normalize to UTC. + +If a write path tags a value as UTC (e.g. appending `Z`) and then formats it with `new Date(...).getFullYear()` (which reads in the Node process's local zone), the SQL string sent to MP carries the server-local clock numbers for an instant that was tagged UTC. MP stores those numbers as if they were already in MP-TZ, so the record drifts by the server-to-MP offset. The mirror anti-pattern on the read path — `new Date(stringFromMp).toLocaleDateString(...)` — re-parses MP's wall-clock-in-MP-TZ as if it were browser-local, drifting again. When an edit form reads the already-shifted value and the write re-applies the transform, each edit shifts the date by another day. + +Concrete example from the source repo: a customer saved a Contact Log at 11:33 PM Eastern on 2026-05-17. It saved as 2026-05-16 at 8:00 PM. Editing without changing any field shifted the date back another day every time. + +The fix has three pieces: + +1. **A boundary service** that knows the MP timezone, returns it as IANA, and converts values without going through `Date.toISOString()` or `Date.getFullYear()`. +2. **A grep-and-fix pass** on every site that touches an MP date column — forms, server actions, services, display formatters. +3. **A reference doc + CLAUDE.md update** so future MP date work doesn't re-introduce the pattern. + +## Phase 1 — Discovery + +Before writing any code, answer these by reading the repo: + +1. **Where does `MPHelper` live, and what is its import path?** + - In the source repo: `@/lib/providers/ministry-platform` exporting `MPHelper`, with `getDomainInfo()` returning `{ TimeZoneName, DisplayName, CultureName, ... }`. + - In *this* repo it may be at a different path, named differently (e.g. `MinistryPlatformClient`), or expose domain info via a different method. + - Find it: `grep -r "getDomainInfo" src/` and inspect the type. Confirm it returns a `TimeZoneName` field. + - **Stop and ask the user** if `getDomainInfo` doesn't exist or doesn't surface a time zone. Don't invent it. + +2. **Where do services live?** (e.g. `src/services/`, `src/lib/services/`, `app/services/`). Match the existing convention. + +3. **Where do shared server actions live?** Some Next.js repos have `src/components/shared-actions/`, others have `src/app/actions/`, others have just `_actions.ts` files. Match the convention. + +4. **Which testing framework?** Vitest or Jest. The mock patterns differ slightly. The source repo uses Vitest with `vi.hoisted()` and mocks `MPHelper` as a class. Adapt as needed. + +5. **Path alias.** Most repos use `@/*` for `src/*`. Verify in `tsconfig.json`. + +6. **MP date fields in this repo.** Grep for anti-patterns (these are the bugs you'll fix): + ``` + grep -rn "T00:00:00.000Z" src/ + grep -rn "T00:00:00Z" src/ + grep -rn "\.toISOString()" src/ + grep -rn "new Date(.*)\.getFullYear()" src/ + grep -rn "new Date(.*)\.getMonth()" src/ + grep -rn "new Date(.*)\.toLocaleDateString" src/ + grep -rn "new Date(.*)\.toLocaleString" src/ + ``` + Also enumerate every component/server-action that writes a column ending in `_Date`, `_DateTime`, `_Time`, `Date`, or `Time` to MP. Common ones: `Contact_Date`, `Start_Date`, `End_Date`, `Birthdate`, `Donation_Date`, `Event_Start_Date`, `Pledge_Start_Date`. + +7. **Is there already a partial fix in place?** Search for `TimeZoneName`, `timezone`, `tz`, `getDomainInfo` to see if anyone has started this work. If so, don't duplicate — extend it. + +Write the answers down (you can use TaskCreate to track them). Only proceed to Phase 2 when each question has an answer or has been raised with the user. + +## Phase 2 — Drop in the service + +Create `src/services/domainTimezoneService.ts` (or wherever services live in this repo) with the contents below. Adjust the `MPHelper` import path if Phase 1 found it lives elsewhere. The file is self-contained — no other dependencies. + +```ts +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<string, string> = { + "Dateline Standard Time": "Etc/GMT+12", + "UTC-11": "Etc/GMT+11", + "Aleutian Standard Time": "America/Adak", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Marquesas Standard Time": "Pacific/Marquesas", + "Alaskan Standard Time": "America/Anchorage", + "UTC-09": "Etc/GMT+9", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "UTC-08": "Etc/GMT+8", + "Pacific Standard Time": "America/Los_Angeles", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time (Mexico)": "America/Mazatlan", + "Mountain Standard Time": "America/Denver", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time": "America/Chicago", + "Easter Island Standard Time": "Pacific/Easter", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Eastern Standard Time": "America/New_York", + "Haiti Standard Time": "America/Port-au-Prince", + "Cuba Standard Time": "America/Havana", + "US Eastern Standard Time": "America/Indianapolis", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "Paraguay Standard Time": "America/Asuncion", + "Atlantic Standard Time": "America/Halifax", + "Venezuela Standard Time": "America/Caracas", + "Central Brazilian Standard Time": "America/Cuiaba", + "SA Western Standard Time": "America/La_Paz", + "Pacific SA Standard Time": "America/Santiago", + "Newfoundland Standard Time": "America/St_Johns", + "Tocantins Standard Time": "America/Araguaina", + "E. South America Standard Time": "America/Sao_Paulo", + "SA Eastern Standard Time": "America/Cayenne", + "Argentina Standard Time": "America/Buenos_Aires", + "Greenland Standard Time": "America/Godthab", + "Montevideo Standard Time": "America/Montevideo", + "Magallanes Standard Time": "America/Punta_Arenas", + "Saint Pierre Standard Time": "America/Miquelon", + "Bahia Standard Time": "America/Bahia", + "UTC-02": "Etc/GMT+2", + "Azores Standard Time": "Atlantic/Azores", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + UTC: "Etc/UTC", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Morocco Standard Time": "Africa/Casablanca", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Jordan Standard Time": "Asia/Amman", + "GTB Standard Time": "Europe/Bucharest", + "Middle East Standard Time": "Asia/Beirut", + "Egypt Standard Time": "Africa/Cairo", + "E. Europe Standard Time": "Europe/Chisinau", + "Syria Standard Time": "Asia/Damascus", + "West Bank Standard Time": "Asia/Hebron", + "South Africa Standard Time": "Africa/Johannesburg", + "FLE Standard Time": "Europe/Kiev", + "Israel Standard Time": "Asia/Jerusalem", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Sudan Standard Time": "Africa/Khartoum", + "Libya Standard Time": "Africa/Tripoli", + "Namibia Standard Time": "Africa/Windhoek", + "Arabic Standard Time": "Asia/Baghdad", + "Turkey Standard Time": "Europe/Istanbul", + "Arab Standard Time": "Asia/Riyadh", + "Belarus Standard Time": "Europe/Minsk", + "Russian Standard Time": "Europe/Moscow", + "E. Africa Standard Time": "Africa/Nairobi", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Azerbaijan Standard Time": "Asia/Baku", + "Russia Time Zone 3": "Europe/Samara", + "Mauritius Standard Time": "Indian/Mauritius", + "Saratov Standard Time": "Europe/Saratov", + "Georgian Standard Time": "Asia/Tbilisi", + "Volgograd Standard Time": "Europe/Volgograd", + "Caucasus Standard Time": "Asia/Yerevan", + "Afghanistan Standard Time": "Asia/Kabul", + "West Asia Standard Time": "Asia/Tashkent", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Pakistan Standard Time": "Asia/Karachi", + "Qyzylorda Standard Time": "Asia/Qyzylorda", + "India Standard Time": "Asia/Calcutta", + "Sri Lanka Standard Time": "Asia/Colombo", + "Nepal Standard Time": "Asia/Katmandu", + "Central Asia Standard Time": "Asia/Almaty", + "Bangladesh Standard Time": "Asia/Dhaka", + "Omsk Standard Time": "Asia/Omsk", + "Myanmar Standard Time": "Asia/Rangoon", + "SE Asia Standard Time": "Asia/Bangkok", + "Altai Standard Time": "Asia/Barnaul", + "W. Mongolia Standard Time": "Asia/Hovd", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Tomsk Standard Time": "Asia/Tomsk", + "China Standard Time": "Asia/Shanghai", + "North Asia East Standard Time": "Asia/Irkutsk", + "Singapore Standard Time": "Asia/Singapore", + "W. Australia Standard Time": "Australia/Perth", + "Taipei Standard Time": "Asia/Taipei", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Aus Central W. Standard Time": "Australia/Eucla", + "Transbaikal Standard Time": "Asia/Chita", + "Tokyo Standard Time": "Asia/Tokyo", + "North Korea Standard Time": "Asia/Pyongyang", + "Korea Standard Time": "Asia/Seoul", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Tasmania Standard Time": "Australia/Hobart", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Bougainville Standard Time": "Pacific/Bougainville", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Magadan Standard Time": "Asia/Magadan", + "Norfolk Standard Time": "Pacific/Norfolk", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Russia Time Zone 11": "Asia/Kamchatka", + "New Zealand Standard Time": "Pacific/Auckland", + "UTC+12": "Etc/GMT-12", + "Fiji Standard Time": "Pacific/Fiji", + "Chatham Islands Standard Time": "Pacific/Chatham", + "UTC+13": "Etc/GMT-13", + "Tonga Standard Time": "Pacific/Tongatapu", + "Samoa Standard Time": "Pacific/Apia", + "Line Islands Standard Time": "Pacific/Kiritimati", +}; + +/** + * Resolves an MP-provided time zone identifier to an IANA name. Accepts either a + * Windows zone (MP's typical output, e.g. "Eastern Standard Time") or an IANA + * name already (e.g. "America/New_York"). Throws if the value is unknown so + * callers fail fast rather than silently drift to the server's local zone. + */ +export function resolveIanaTimezone(timeZone: string): string { + if (!timeZone || typeof timeZone !== "string") { + throw new Error("Time zone identifier is required"); + } + const trimmed = timeZone.trim(); + if (trimmed.length === 0) { + throw new Error("Time zone identifier is required"); + } + if (trimmed === "UTC" || trimmed === "Etc/UTC") { + return "Etc/UTC"; + } + if (trimmed.includes("/")) { + return trimmed; + } + const mapped = WINDOWS_TO_IANA[trimmed]; + if (!mapped) { + throw new Error( + `Unknown time zone "${trimmed}" — add it to the Windows→IANA mapping in domainTimezoneService.ts` + ); + } + return mapped; +} + +function parseWallClockParts(value: string): { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} | null { + const trimmed = value.trim(); + if (/Z$/.test(trimmed) || /[+-]\d{2}:?\d{2}$/.test(trimmed)) { + return null; + } + const match = trimmed.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?$/ + ); + if (!match) { + return null; + } + const [, y, mo, d, h = "00", mi = "00", s = "00"] = match; + return { + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(h), + minute: Number(mi), + second: Number(s), + }; +} + +function formatInstantAsMpSql(instant: Date, ianaTimeZone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: ianaTimeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(instant); + const lookup: Record<string, string> = {}; + for (const part of parts) { + lookup[part.type] = part.value; + } + // Some ICU builds emit "24" for midnight under hour12:false; normalize. + const hour = lookup.hour === "24" ? "00" : lookup.hour; + return `${lookup.year}-${lookup.month}-${lookup.day} ${hour}:${lookup.minute}:${lookup.second}`; +} + +/** + * DomainTimezoneService — singleton helper for converting date/time values + * between MP's domain time zone and the application's various surfaces. + * + * Why this exists: MP stores datetimes as wall-clock values in the domain's + * configured time zone (NOT UTC). Sending a UTC-tagged value or letting + * `new Date(...).getFullYear()` round-trip through the server's local time + * silently shifts dates by the offset between server and MP. + */ +export class DomainTimezoneService { + private static instance: DomainTimezoneService | null = null; + private mp: MPHelper; + private cachedIana: string | null = null; + private inflight: Promise<string> | null = null; + + private constructor() { + this.mp = new MPHelper(); + } + + public static getInstance(): DomainTimezoneService { + if (!DomainTimezoneService.instance) { + DomainTimezoneService.instance = new DomainTimezoneService(); + } + return DomainTimezoneService.instance; + } + + public async getMpTimezone(): Promise<string> { + if (this.cachedIana) { + return this.cachedIana; + } + if (!this.inflight) { + this.inflight = (async () => { + const info = await this.mp.getDomainInfo(); + const iana = resolveIanaTimezone(info.TimeZoneName); + this.cachedIana = iana; + return iana; + })().finally(() => { + this.inflight = null; + }); + } + return this.inflight; + } + + /** + * Converts a value into the SQL datetime string MP's table API expects + * ("YYYY-MM-DD HH:MM:SS" in the MP domain's wall-clock time). + * + * - Wall-clock string with no zone marker → reformatted as MP-TZ wall-clock, + * missing components default to zero. + * - String with trailing "Z" or "±HH:MM" offset → parsed as a UTC/offset + * instant and converted into MP-TZ wall-clock. + * - `Date` instances → converted as UTC instants. + */ + public async toMpSqlDatetime(value: Date | string): Promise<string> { + if (value instanceof Date) { + const iana = await this.getMpTimezone(); + return formatInstantAsMpSql(value, iana); + } + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("toMpSqlDatetime: value must be a non-empty string or Date"); + } + const wallClock = parseWallClockParts(value); + if (wallClock) { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${wallClock.year}-${pad(wallClock.month)}-${pad(wallClock.day)} ${pad(wallClock.hour)}:${pad(wallClock.minute)}:${pad(wallClock.second)}`; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`toMpSqlDatetime: unable to parse "${value}"`); + } + const iana = await this.getMpTimezone(); + return formatInstantAsMpSql(parsed, iana); + } + + /** + * Parses an MP wall-clock datetime string into a `Date` instant. Use when + * you need real arithmetic on values returned from MP — for display, prefer + * `Intl.DateTimeFormat({ timeZone })` directly against the raw string. + */ + public async parseMpDatetime(value: string): Promise<Date> { + const wallClock = parseWallClockParts(value); + if (!wallClock) { + const direct = new Date(value); + if (Number.isNaN(direct.getTime())) { + throw new Error(`parseMpDatetime: unable to parse "${value}"`); + } + return direct; + } + const iana = await this.getMpTimezone(); + const utcGuess = Date.UTC( + wallClock.year, + wallClock.month - 1, + wallClock.day, + wallClock.hour, + wallClock.minute, + wallClock.second + ); + const projected = formatInstantAsMpSql(new Date(utcGuess), iana); + const projectedParts = parseWallClockParts(projected)!; + const projectedUtc = Date.UTC( + projectedParts.year, + projectedParts.month - 1, + projectedParts.day, + projectedParts.hour, + projectedParts.minute, + projectedParts.second + ); + const offset = utcGuess - projectedUtc; + return new Date(utcGuess + offset); + } + + /** Test hook — clears cached domain info so the next call refetches. */ + public clearCache(): void { + this.cachedIana = null; + this.inflight = null; + } +} + +export const domainTimezoneService = DomainTimezoneService.getInstance(); +``` + +## Phase 3 — Add the service tests + +Create `src/services/domainTimezoneService.test.ts` next to the service. The mock pattern matters: this repo's singleton is constructed at module load, so the `MPHelper` mock must be set up before the import, which requires `vi.hoisted()` under Vitest. Under Jest, use `jest.mock` factory with the variable referenced at the bottom of the file or via `jest.requireActual` patterns — adapt to local conventions. + +```ts +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(() => { + // 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"); + }); + }); +}); +``` + +Run the service tests in isolation first: + +``` +npm run test:run -- src/services/domainTimezoneService.test.ts +``` + +Then re-run under two zones to prove the math is server-TZ independent: + +``` +TZ=UTC npm run test:run -- src/services/domainTimezoneService.test.ts +TZ=America/Los_Angeles npm run test:run -- src/services/domainTimezoneService.test.ts +``` + +All three runs must pass before moving on. + +## Phase 4 — Add the shared server action for client display + +Create a server action that exposes the MP timezone to client components. In the source repo this lives at `src/components/shared-actions/domain.ts`; in this repo, match the local convention from Phase 1. + +```ts +'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(); +} +``` + +## Phase 5 — Find and fix every MP datetime site + +For each anti-pattern site Phase 1 found, apply the appropriate recipe below. **Confirm the field is an MP datetime column** (not a local-only field, not a Better Auth field, not a UI-only filter) before changing anything. + +### Recipe A — form sends `${date}T00:00:00.000Z` + +**Symptom:** A form with `<input type="date">` (or a hidden field) appends a `Z` suffix or builds an ISO string before submission. + +**Fix:** Send the raw date string. The service handles the SQL formatting. + +```diff +- Contact_Date: `${data.contactDate}T00:00:00.000Z`, ++ Contact_Date: data.contactDate, +``` + +### Recipe B — service does `new Date(x).getFullYear()` round-trip + +**Symptom:** A service or server action receives a datetime string and reformats it using `new Date(x)` + `.getFullYear()` / `.getMonth()` / `.getDate()` / `.getHours()`. This reads in the **server's local zone**, which is silently wrong. + +**Fix:** Route the value through `DomainTimezoneService.toMpSqlDatetime()`. + +```diff ++ import { DomainTimezoneService } from "@/services/domainTimezoneService"; ++ + public async createContactLog(input: ContactLogInput): Promise<ContactLog> { +- if (input.Contact_Date) { +- const date = new Date(input.Contact_Date); +- const year = date.getFullYear(); +- const month = String(date.getMonth() + 1).padStart(2, '0'); +- // ... etc, building "YYYY-MM-DD HH:MM:SS" +- input.Contact_Date = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +- } ++ const tz = DomainTimezoneService.getInstance(); ++ const mpDate = await tz.toMpSqlDatetime(input.Contact_Date); + // ... pass mpDate through to MP + } +``` + +If the existing code validates a Zod-generated schema that declares `Contact_Date: z.string().datetime()` (ISO format), validate the **non-date** fields with the schema and re-attach the converted SQL string afterwards: + +```ts +const { Contact_Date, ...rest } = input; +const validatedRest = RecordSchema + .omit({ Some_PK_ID: true, Contact_Date: true }) + .parse(rest); +const tz = DomainTimezoneService.getInstance(); +const mpDate = await tz.toMpSqlDatetime(Contact_Date); +const payload = { ...validatedRest, Contact_Date: mpDate }; +``` + +### Recipe C — display uses `new Date(stringFromMp).toLocaleDateString(...)` + +**Symptom:** A client component renders an MP datetime via `new Date(...).toLocaleDateString()` or `.toLocaleString()`. This parses MP's wall-clock-in-MP-TZ as **browser-local**, then formats in the user's zone — silently wrong for any user not in MP-TZ. + +**Fix:** Receive the MP timezone (IANA) as a prop from a server component that called `getMpTimezone()`. Format with `Intl.DateTimeFormat({ timeZone })`. To convert MP's wall-clock string to the matching UTC instant for the formatter, build a candidate UTC and correct by the round-trip offset (same algorithm the service uses internally): + +```tsx +function formatMpDateTime(mpString: string, mpTimezone: string): string { + const normalized = mpString.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; + const utcGuess = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: mpTimezone, + 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(parts.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(mpString); + } + return new Intl.DateTimeFormat("en-US", { + timeZone: mpTimezone, + month: "short", day: "numeric", year: "numeric", + hour: "numeric", minute: "2-digit", + }).format(instant); +} +``` + +Wire the timezone into the component tree: + +```tsx +// Server component (page or layout) +import { getMpTimezone } from "@/components/shared-actions/domain"; +const mpTimezone = await getMpTimezone(); +return <ContactLogs mpTimezone={mpTimezone} ... />; +``` + +### Recipe D — edit form pre-fill via `new Date(...)` + +**Symptom:** An edit form reads an MP datetime and re-parses it: `setValue("date", new Date(log.Contact_Date).toISOString().split("T")[0])` or similar. + +**Fix:** Since MP returns wall-clock in MP-TZ already, take string slices directly. For a date input: + +```tsx +setValue("contactDate", log.Contact_Date.split("T")[0]); +``` + +For a `datetime-local` input (`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`; +} +setValue("contactDate", toDatetimeLocalValue(log.Contact_Date)); +``` + +### Recipe E — `$filter` date literal built from `Date.toISOString()` + +**Symptom:** A filter string is built using `.toISOString()` or `.toUTCString()`. + +**Fix:** MP filters interpret literals in MP-TZ. Use `toMpSqlDatetime` to produce the right string: + +```ts +const tz = DomainTimezoneService.getInstance(); +const cutoff = await tz.toMpSqlDatetime(new Date()); +const filter = `Last_Activity_Date >= '${cutoff}'`; +``` + +### After every fix + +Update or add a test that asserts the produced string matches MP-TZ wall-clock. Run the suite under both `TZ=UTC` and `TZ=America/Los_Angeles`; the test must pass under both. + +## Phase 6 — Add a round-trip regression test for one real datetime field + +Pick whichever MP write path you fixed and add a test that proves the date no longer drifts. Pattern (from the contact-log fix in the source repo): + +```ts +it("regression: round-tripping the same edit does not shift the date", async () => { + mockUpdateTableRecords.mockResolvedValue([{ Contact_Log_ID: 1 }]); + + const service = await ContactLogService.getInstance(); + 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"); + } +}); +``` + +If a service test file uses the singleton, reset both singletons in `beforeEach` and mock `getDomainInfo`: + +```ts +beforeEach(() => { + mockGetDomainInfo.mockReset(); + mockGetDomainInfo.mockResolvedValue({ TimeZoneName: "America/New_York" }); + (ContactLogService as any).instance = undefined; + (DomainTimezoneService as any).instance = null; +}); +``` + +## Phase 7 — Write the reference doc + +Create `.claude/references/ministryplatform.datetimehandling.md` with this content. Adjust import paths if this repo uses a different alias or directory layout. + +````markdown +# 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: a Contact Log entry created at 11:33 PM Eastern on 2026-05-17 saved 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()` (Vitest) because the singleton'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. `clearAllMocks` doesn't drain `mockResolvedValueOnce` 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. +```` + +## Phase 8 — Update CLAUDE.md + +Two edits to the repo's `CLAUDE.md`: + +**1.** Add a new bullet to the **Key Development Practices** section. Number it to follow the existing list (in the source repo this was #10; in this repo it may be different — match the local convention): + +``` +N. **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)**. +``` + +**2.** Add a line to the **Reference Documents** section pointing to the new doc: + +``` +- **[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 +``` + +If this repo's CLAUDE.md doesn't have a "Reference Documents" section yet, create it after Key Development Practices. + +## Phase 9 — Verify + +Before declaring done: + +1. `npm run lint` — clean. +2. `npm run test:run` (or whatever the local test command is) — all tests pass. +3. `npx tsc --noEmit` — no new type errors. Pre-existing errors in unrelated files are OK; note them in the PR description. +4. Run the service tests under `TZ=UTC` and `TZ=America/Los_Angeles` and confirm both pass: + ``` + TZ=UTC npm run test:run -- src/services/domainTimezoneService.test.ts + TZ=America/Los_Angeles npm run test:run -- src/services/domainTimezoneService.test.ts + ``` +5. Spot-check one bug fix manually if possible: open the app, exercise a date-handling feature, confirm the saved value matches what was entered and that editing without changing fields doesn't shift the value. + +## Phase 10 — Branch, commit, PR + +Use the repo's existing conventions. The source repo's commit message looked like this; adapt the scope to whichever feature carried the bug in this repo: + +``` +fix(<scope>): correct timezone handling on <field> save/edit + +MP stores datetimes as wall-clock values in the domain's configured time +zone, not UTC. <Existing path> tagged values as UTC and round-tripped them +through `new Date(...).getFullYear()`, producing strings in the Node +server's local zone instead of MP's. Edits compounded the drift. + +- Add DomainTimezoneService — singleton wrapping getDomainInfo() with + Windows→IANA mapping and SQL datetime conversion. +- Add shared server action `getMpTimezone()` for client-side display. +- Fix <component/service> to route MP date columns through the service. +- Add reference doc `.claude/references/ministryplatform.datetimehandling.md`. +- Update CLAUDE.md with Key Development Practice + reference link. + +Tests: <new count> new, <round-trip regression>, suite passes under +TZ=UTC and TZ=America/Los_Angeles. +``` + +Open the PR, request review, do not self-merge unless that's normal in this repo. + +## What "done" looks like + +- [ ] `src/services/domainTimezoneService.ts` exists and is the only file calling `MPHelper.getDomainInfo()` for `TimeZoneName`. +- [ ] `src/services/domainTimezoneService.test.ts` exists with at least the 16 tests above; passes under `TZ=UTC` and `TZ=America/Los_Angeles`. +- [ ] No remaining hits in `src/` for these greps on MP-bound date columns: + - `T00:00:00.000Z` + - `.toISOString()` *near* an MP field + - `new Date(.*).getFullYear()` *near* an MP read/write + - `new Date(.*).toLocaleDateString` *for an MP-sourced value* +- [ ] At least one feature has a round-trip regression test asserting no drift across three save cycles. +- [ ] `.claude/references/ministryplatform.datetimehandling.md` exists. +- [ ] `CLAUDE.md` has a Key Development Practices bullet and a Reference Documents entry pointing to it. +- [ ] Lint and full test suite pass. + +If you hit something the playbook doesn't cover — a different MPHelper shape, an unusual existing partial fix, a non-MP date that grep flagged — stop and ask the user before improvising. diff --git a/.claude/references/ministryplatform.datetimehandling.md b/.claude/references/ministryplatform.datetimehandling.md new file mode 100644 index 0000000..87b5abc --- /dev/null +++ b/.claude/references/ministryplatform.datetimehandling.md @@ -0,0 +1,134 @@ +# 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: a Contact Log entry created at 11:33 PM Eastern on 2026-05-17 saved 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/app/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); +``` + +For embed-SDK Web Components specifically, the IANA zone must be fetched (or returned in the API payload) and passed into the component — see `src/services/fullCalendarService.ts` for an example of routing the value through `DomainTimezoneService.toMpSqlDatetime` on the server side before MP `$filter` is composed. + +### 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 or an ISO/Z-tagged string 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 singleton'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. `clearAllMocks` doesn't drain `mockResolvedValueOnce` queues, and tests that don't hit `getMpTimezone()` leave queue entries behind that leak forward. +4. **Run under multiple `TZ` env vars** — at minimum `TZ=UTC` and `TZ=America/Los_Angeles`. The original bug was invisible when developer machines and the server happened to be in the same zone as the MP domain. diff --git a/.claude/references/ministryplatform.schema.md b/.claude/references/ministryplatform.schema.md index 1510060..42ee673 100644 --- a/.claude/references/ministryplatform.schema.md +++ b/.claude/references/ministryplatform.schema.md @@ -1,6 +1,6 @@ # Ministry Platform Schema Reference -This document provides a summary of Ministry Platform database tables for LLM assistants working on the NorthwoodsNext project. +This document provides a summary of Ministry Platform database tables for LLM assistants working on this project. **Generated:** 2026-03-25T14:07:09.466Z **Tables:** 301 diff --git a/.env.example b/.env.example index cb7dfd6..e25625d 100644 --- a/.env.example +++ b/.env.example @@ -6,23 +6,38 @@ BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=your-better-auth-secret-min-32-chars # ============================================================================ -# OIDC Provider (Ministry Platform OAuth) +# OIDC Provider (Ministry Platform OAuth — interactive user login) # ============================================================================ +# Credentials for the OAuth client used to sign users in. If unset, the code +# falls back to MINISTRY_PLATFORM_CLIENT_ID / _SECRET below (src/lib/auth.ts), +# so you only need these if your login client differs from the API client. OIDC_CLIENT_ID=your-oidc-client-id OIDC_CLIENT_SECRET=your-oidc-client-secret # ============================================================================ -# Ministry Platform API (service-to-service) +# Ministry Platform API (service-to-service / client credentials) # ============================================================================ +# Base URL must include the /ministryplatformapi suffix; server code strips it +# where a bare host is needed (src/lib/embed/config.ts). Required by the type +# generator (pnpm generate:types) and all MP-backed services. MINISTRY_PLATFORM_BASE_URL=https://your-mp-instance.com/ministryplatformapi MINISTRY_PLATFORM_CLIENT_ID=your-client-id MINISTRY_PLATFORM_CLIENT_SECRET=your-client-secret +# Organization display name baked into the embed SDK at build time (e.g. the +# SMS opt-in consent text in the profile widget). Unset falls back to a neutral +# phrase ("our organization"). Consumed by Vite at build (packages/embed-sdk). +VITE_ORG_NAME= + # ============================================================================ # Public Keys (exposed to the browser) # ============================================================================ NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL=https://your-mp-instance.com/ministryplatformapi/files -NEXT_PUBLIC_APP_NAME=MPNext-Components +NEXT_PUBLIC_APP_NAME=MPNext-Widgets + +# NEXT_PUBLIC_APP_VERSION is NOT set here — it is derived automatically at build +# time from the repo's VERSION file (next.config.ts), defaulting to "dev". +# Setting it manually has no effect. # ============================================================================ # Embed Widget Settings @@ -31,7 +46,10 @@ NEXT_PUBLIC_APP_NAME=MPNext-Components # Generate via: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" EMBED_JWT_SECRET= -# Comma-separated origins allowed to call the embed API +# Comma-separated origins allowed to call the embed API. +# On Vercel, VERCEL_URL and VERCEL_PROJECT_PRODUCTION_URL (injected +# automatically by the platform) are also added to the allowlist — no need to +# set those yourself locally. EMBED_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 # ============================================================================ @@ -45,8 +63,8 @@ RECAPTCHA_SECRET_KEY= # Demo Access Control (src/app/(demo)/...) # ============================================================================ # Comma-separated MP User_Group_ID values whose members can view /demo pages. -# Defaults to "73" in code, which is a Northwoods-specific group ID — set this -# to your own staff/admin group(s) for any other tenant. +# No default — set this to your own staff/admin group(s). If unset (and +# DEMO_PUBLIC_ACCESS is not enabled), group-based demo access is denied. DEMO_ACCESS_GROUP_IDS= # Set to "true" or "authenticated" to grant any signed-in user access to /demo @@ -57,8 +75,8 @@ DEMO_PUBLIC_ACCESS= # Full Calendar Admin (src/services/fullCalendarService.ts) # ============================================================================ # Comma-separated MP User_Group_ID values whose members are treated as -# calendar admins. Defaults to "22" in code (Northwoods-specific) — override -# for your tenant. +# calendar admins. No default — set this for your tenant. If unset, no users +# are treated as calendar admins. CALENDAR_ADMIN_GROUP_IDS= # ============================================================================ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/CLAUDE.md b/CLAUDE.md index 5cdc764..1d37dec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,20 @@ -# CLAUDE.md - MPNext-Components +# CLAUDE.md - MPNext-Widgets ## Overview -**pnpm monorepo**: Component-only extraction from NorthwoodsNext. Contains 3 embed SDK widgets (user-menu, add-to-calendar, full-calendar) with their supporting API routes, services, and shared types. The embed SDK builds framework-agnostic Web Components (Shadow DOM) loaded via `<script>` on external sites. +**pnpm monorepo**: Component-only embed SDK extraction. Contains 5 embed SDK widgets (user-menu, add-to-calendar, full-calendar, profile, my-invoices) with their supporting API routes, services, and shared types. The embed SDK builds framework-agnostic Web Components (Shadow DOM) loaded via `<script>` on external sites. ## Structure ``` src/ # Next.js 16 (App Router) -├── app/api/embed/ # Widget API endpoints (subset for 3 widgets) -├── services/ # Singleton services (addToCalendar, fullCalendar, profile, subscription, user) +├── app/api/embed/ # Widget API endpoints (subset for 5 widgets) +├── services/ # Singleton services (addToCalendar, fullCalendar, profile, subscription, user, invoice, domainTimezone) ├── lib/embed/ # Widget auth (JWT, CORS, tenant config) ├── lib/providers/ministry-platform/ # MP REST API (MPHelper, models, auth) packages/ ├── embed-sdk/ # @mpnext/embed-sdk (Vite library) -│ ├── src/components/ # 3 Web Components (next-* custom elements) +│ ├── src/components/ # 5 Web Components (next-* custom elements) │ ├── src/shared/ # base-widget.ts, api-client.ts, cdn-loader.ts │ └── demo-*.html # Per-widget demo pages + index.html └── types/ # @mpnext/types (Zod schemas + TS interfaces) @@ -43,18 +43,18 @@ Manual widget testing via `pnpm test:widget` (opens http://localhost:5173). Play **Playwright test account**: `PLAYWRIGHT_MP_USERNAME` / `PLAYWRIGHT_MP_PASSWORD` in `.env.local`. This is a non-admin MP OAuth user with **MFA disabled**. -**Dev tenant**: `northwoods-dev` in `src/lib/embed/config.ts`. Allowed origins: `localhost:3000`, `localhost:5173` (and 127.0.0.1 variants). Init token: `northwoods-dev_dev-secret`. +**Dev auth**: Widget session auth is origin-based — no tenant id or init token. The `/api/embed/session` route validates the request origin against `EMBED_ALLOWED_ORIGINS` (`src/lib/embed/config.ts`). Local dev origins: `localhost:3000`, `localhost:5173` (and 127.0.0.1 variants). ## Widget Architecture -1. External site loads `nw-embed.es.js` via `<script type="module">` +1. External site loads `next-embed.es.js` via `<script type="module">` 2. `MPNextEmbed.init()` sets token provider 3. Token provider fetches JWT (5-min expiry) from `/api/embed/session` 4. Widgets render in Shadow DOM; API calls use Bearer token with auto-refresh on 401 -**Design**: Web Components + Shadow DOM (no framework deps, ~5KB gzip), JWT+CORS auth, idempotency keys, multi-tenant origin allowlists. +**Design**: Web Components + Shadow DOM (no framework deps, ~33KB gzip), JWT+CORS auth, multi-tenant origin allowlists. -**3 widgets**: `next-user-menu`, `next-add-to-calendar`, `next-full-calendar` +**5 widgets**: `next-user-menu`, `next-add-to-calendar`, `next-full-calendar`, `next-profile`, `next-my-invoices` **MP widget styling**: `public/embed-sdk/mp-widget-overrides.css` injected into MP Shadow DOM widgets via `customcss` attribute. User-menu applies this automatically. @@ -62,7 +62,13 @@ Manual widget testing via `pnpm test:widget` (opens http://localhost:5173). Play All services follow singleton pattern: `const svc = await ServiceName.getInstance()`. Each wraps `MPHelper`. -Services: `addToCalendar`, `fullCalendar`, `profile`, `subscription`, `user` +Services: `addToCalendar`, `fullCalendar`, `profile`, `subscription`, `user`, `invoice`, `domainTimezone` + +## MP Date/Time Handling + +**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. Server-side, route writes/filters through `DomainTimezoneService.getInstance().toMpSqlDatetime(...)`. Client-side, format MP values with `Intl.DateTimeFormat({ timeZone })` using the IANA zone from `getMpTimezone()` (`src/app/actions/domain.ts`). + +See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. ## Code Conventions @@ -109,8 +115,11 @@ await mp.executeProcedure('ProcName', { param: 'value' }); | `src/lib/embed/auth.ts` | `requireWidgetAuth()` -- accepts `widget: string \| string[]` | | `src/lib/embed/config.ts` | Tenant configs & allowed origins | | `src/lib/embed/jwt.ts` | Widget JWT creation/verification | -| `packages/embed-sdk/src/index.ts` | SDK entry point -- registers 3 widgets | +| `packages/embed-sdk/src/index.ts` | SDK entry point -- registers 5 widgets | | `packages/embed-sdk/src/shared/base-widget.ts` | Abstract base class (Shadow DOM, token mgmt, fetch) | | `packages/embed-sdk/vite.config.ts` | Vite library mode (ES + UMD output) | | `public/embed-sdk/mp-widget-overrides.css` | Brand CSS for MP Shadow DOM widgets | | `.claude/references/ministryplatform.query-syntax.md` | MP REST API query syntax reference (`$filter`, `$select`, `_TABLE` traversal) | +| `.claude/references/ministryplatform.datetimehandling.md` | How to send/receive MP datetimes safely via `DomainTimezoneService`, anti-patterns, Windows↔IANA mapping, test guidance | +| `src/services/domainTimezoneService.ts` | Singleton: MP domain TZ → IANA, `toMpSqlDatetime`, `parseMpDatetime` | +| `src/app/actions/domain.ts` | `getMpTimezone()` server action for client-side `Intl.DateTimeFormat` rendering | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5abac09 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +Copyright (c) 2026 ACS Technologies (ACST). All rights reserved. + +This software and its associated source code, documentation, and assets +(the "Software") are proprietary and confidential to ACS Technologies (ACST). + +No part of the Software may be used, copied, modified, merged, published, +distributed, sublicensed, or disclosed, in whole or in part, by any means, +without the prior explicit written permission of ACS Technologies (ACST). + +Unauthorized use, reproduction, or distribution of the Software, or any +portion of it, is strictly prohibited. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. diff --git a/README.md b/README.md index d3ff888..1ecf9fa 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,11 @@ Embeddable Web Component widgets for [Ministry Platform](https://www.ministrypla - **Five embeddable widgets**: `next-user-menu`, `next-add-to-calendar`, `next-full-calendar`, `next-profile`, `next-my-invoices` — each a framework-agnostic Web Component rendered in Shadow DOM - **Framework-agnostic SDK**: Single `<script type="module">` tag loads `next-embed.es.js`; no React, jQuery, or build tooling required on the host site -- **Multi-tenant JWT auth**: Short-lived (5-min) widget JWTs (HS256) with per-tenant CORS allowlists, auto-refresh on 401, idempotency keys +- **JWT widget auth**: Short-lived (5-min) widget JWTs (HS256) gated by a CORS origin allowlist, with automatic token refresh on 401 - **Authentication**: Better Auth with Ministry Platform OAuth (via `genericOAuth` plugin) and OIDC RP-initiated logout - **Type-Safe API**: Shared `@mpnext/types` package with Zod schemas + TypeScript types used on both sides of the wire - **Next.js 16**: App Router with React Server Components, Turbopack, and a demo gallery for every widget -- **Cache-busting loader**: `next-embed.js` redirects to a hashed bundle so external pages always pick up the latest build +- **Cache-busting loader**: `next-embed.js` is a tiny static loader (regenerated at each build) that imports the content-hashed bundle so external pages always pick up the latest build - **MP type generation**: CLI tool generates TypeScript interfaces and Zod schemas from your Ministry Platform database schema (300+ tables) - **Playwright E2E**: End-to-end widget tests against a real Next.js + Vite demo stack @@ -63,7 +63,7 @@ Embeddable Web Component widgets for [Ministry Platform](https://www.ministrypla External site │ <script type="module" src="https://your-host.com/embed-sdk/next-embed.js"> ▼ -next-embed.js (loader) Reads x-sdk-hash header, redirects to next-embed.<hash>.es.js +next-embed.js (loader) Static loader generated by scripts/hash-sdk.js; imports next-embed.<hash>.es.js │ ▼ next-embed.<hash>.es.js Auto-registers <next-*> custom elements @@ -93,7 +93,7 @@ Two layers, used by different surfaces: ## Prerequisites -- **Node.js**: v18 or higher +- **Node.js**: v20.9 or higher (required by Next.js 16; CI runs on Node 22) - **Package Manager**: **pnpm 10.x** (the `preinstall` guard refuses `npm install` / `yarn install`) - **Ministry Platform**: Active instance with API credentials and an OAuth client configured (see [OAuth Setup](#oauth-setup)) @@ -181,7 +181,7 @@ NEXT_PUBLIC_APP_NAME=MPNext-Widgets EMBED_JWT_SECRET=your_generated_secret EMBED_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 -# Optional gates (default to Northwoods-specific MP group IDs if unset) +# Group-based access gates (no default; unset = no group-based access) DEMO_ACCESS_GROUP_IDS= CALENDAR_ADMIN_GROUP_IDS= ``` @@ -324,7 +324,10 @@ MPNext-Widgets/ │ │ │ ├── page.tsx # Widget catalog │ │ │ └── [slug]/page.tsx # Per-widget demo page │ │ ├── api/ -│ │ │ ├── auth/[...all]/ # Better Auth routes +│ │ │ ├── auth/ +│ │ │ │ ├── [...all]/ # Better Auth catch-all routes +│ │ │ │ ├── logout/ # OIDC RP-initiated logout +│ │ │ │ └── session-tokens/ # Surfaces OAuth tokens to the app │ │ │ └── embed/ # Widget API endpoints │ │ │ ├── session/ # Mint short-lived widget JWTs │ │ │ ├── add-to-calendar/ # Subscribe to event reminders @@ -337,7 +340,6 @@ MPNext-Widgets/ │ │ └── providers.tsx # App providers │ │ │ ├── components/ # React components (host app + demo) -│ ├── contexts/ # React Context providers │ │ │ ├── lib/ │ │ ├── auth.ts # Better Auth server config @@ -502,12 +504,13 @@ Application services live in `src/services/` and provide widget-scoped business | **profileService** | `profileService.ts` | `<next-profile>` | | **invoiceService** | `invoiceService.ts` | `<next-my-invoices>` | | **subscriptionService** | `subscriptionService.ts` | profile + subscription management | +| **domainTimezoneService** | `domainTimezoneService.ts` | MP domain time-zone conversion (used by all services) | ```typescript import { ProfileService } from '@/services/profileService'; const svc = await ProfileService.getInstance(); -const profile = await svc.getProfile({ contactId: 12345 }); +const profile = await svc.getProfileByUserGuid(userGuid); ``` ## Embedding on an External Site @@ -528,7 +531,7 @@ The SDK auto-detects its own origin, wires up a token provider that calls `POST For advanced cases (e.g. proxying tokens through your own backend), call `MPNextEmbed.init()` manually with a custom `tokenProvider`. See `packages/embed-sdk/src/index.ts` for the full API. -**Origin allowlist**: The host page's origin must be in `EMBED_ALLOWED_ORIGINS` (or in a tenant config in `src/lib/embed/config.ts`) — requests from anywhere else are rejected with 403. +**Origin allowlist**: The host page's origin must be in `EMBED_ALLOWED_ORIGINS` (parsed in `src/lib/embed/config.ts`, plus Vercel-detected URLs) — requests from anywhere else are rejected with 403. ## Testing @@ -585,7 +588,7 @@ pnpm build pnpm start ``` -The build runs the SDK build first (Vite library mode → ES + UMD), hashes the output filenames, and copies the bundle into `public/embed-sdk/` so it is served alongside the Next.js app. The `next-embed.js` loader reads an `x-sdk-hash` header to redirect external host pages to the latest hashed bundle. +The build runs the SDK build first (Vite library mode → ES + UMD), hashes the output filenames, and copies the bundle into `public/embed-sdk/` so it is served alongside the Next.js app. `scripts/hash-sdk.js` regenerates the small `next-embed.js` loader so it imports the latest content-hashed bundle. > **Note**: The build process includes TypeScript type checking. Ensure all generated types are up to date by running `pnpm mp:generate:models` before building. @@ -597,9 +600,9 @@ This project includes custom [Claude Code](https://claude.ai/code) commands (ski |---|---| | `/audit-deps` | Security and update audit for dependencies (runs `pnpm audit`, surfaces recent CVEs, categorizes updates) | | `/security-audit` | Security audit of the pending changes on the current branch | +| `/branch-commit` | Create a branch and commit the current changes | +| `/pr` | Open a pull request for the current branch | | `/release` | Cut a new release (version bump, changelog, tag) | -| `/release-finish` | Finalize an in-flight release | -| `/review` | Review a pull request | Command definitions live in `.claude/commands/`. See [`CLAUDE.md`](./CLAUDE.md) for a deeper overview of architecture, services, brand colors, and conventions. @@ -634,7 +637,7 @@ import type { CalendarEvent } from '@mpnext/types'; ### Embed-Side Conventions - All widget API routes call `requireWidgetAuth(req, { widget: 'name' })` — never trust the client -- Tenant configuration (allowed origins, defaults) lives in `src/lib/embed/config.ts` +- Allowed origins and embed config live in `src/lib/embed/config.ts` (`EMBED_ALLOWED_ORIGINS`) - Brand CSS for MP-hosted Shadow DOM widgets is injected from `public/embed-sdk/mp-widget-overrides.css` via the `customcss` attribute ### TypeScript @@ -646,7 +649,7 @@ import type { CalendarEvent } from '@mpnext/types'; 1. **Regenerate types** after MP schema changes: `pnpm mp:generate:models` 2. **Use Zod schemas** when writing to MP — pass `schema:` to `createTableRecords()` / `updateTableRecords()` to catch validation errors before the API call 3. **Add new widgets in three places**: a Web Component in `packages/embed-sdk/src/components/`, a service in `src/services/`, an API route under `src/app/api/embed/`. Register the element in `packages/embed-sdk/src/index.ts` and add a demo page (`demo-<name>.html`). -4. **Add the host page origin** to `EMBED_ALLOWED_ORIGINS` (or a tenant config) before testing on a new external site +4. **Add the host page origin** to `EMBED_ALLOWED_ORIGINS` before testing on a new external site 5. **Access fields with special characters** using bracket notation: `event["Allow_Check-in"]` 6. **Run lint** before committing: `pnpm lint` @@ -656,7 +659,7 @@ This project follows strict TypeScript conventions and code style. Please review ## License -Private +Proprietary and confidential (UNLICENSED). All rights reserved. See [LICENSE](./LICENSE). ## Support diff --git a/eslint.config.mjs b/eslint.config.mjs index efce4fb..3a3868e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,7 @@ const eslintConfig = [ "packages/**", "public/embed-sdk/**", "scripts/**", + "coverage/**", ], }, ]; diff --git a/next.config.ts b/next.config.ts index e9ffa30..8dbc168 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,19 @@ import type { NextConfig } from "next"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +function readVersion(): string { + try { + return readFileSync(join(process.cwd(), "VERSION"), "utf8").trim(); + } catch { + return "dev"; + } +} const nextConfig: NextConfig = { - /* config options here */ + env: { + NEXT_PUBLIC_APP_VERSION: readVersion(), + }, }; export default nextConfig; diff --git a/package.json b/package.json index 6458213..4f7de57 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,11 @@ { - "name": "mpnext-components", + "name": "mpnext-widgets", "version": "0.1.0", "private": true, + "license": "UNLICENSED", + "engines": { + "node": ">=20.9" + }, "workspaces": [ "packages/*" ], @@ -29,57 +33,59 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", - "@hookform/resolvers": "^5.0.1", + "@hookform/resolvers": "^5.4.0", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "better-auth": "^1.6.11", + "better-auth": "^1.6.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.4.2", "jose": "^6.2.3", - "lucide-react": "^1.16.0", + "lucide-react": "^1.17.0", "next": "^16.2.6", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-hook-form": "^7.76.0", + "react-hook-form": "^7.76.1", "tailwind-merge": "^3.6.0", - "tsx": "^4.22.1", + "tsx": "^4.22.3", "zod": "^4.4.3" }, "pnpm": { "overrides": { "cookie": ">=0.7.0", "defu": ">=6.1.7", - "brace-expansion": ">=5.0.5" + "minimatch@3>brace-expansion": "1.1.15", + "postcss": ">=8.5.10", + "kysely": "^0.28.17" } }, "devDependencies": { - "@inquirer/prompts": "^8.4.3", + "@inquirer/prompts": "^8.5.1", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/node": "^25.8.0", - "@types/react": "^19.2.14", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.5.0", "chalk": "^5.6.2", - "concurrently": "^9.2.1", + "concurrently": "^10.0.0", "eslint": "^9", "eslint-config-next": "^16.2.6", "jsdom": "^29.0.0", "playwright": "^1.60.0", - "postcss": "^8.5.14", + "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "tw-animate-css": "^1.3.0", "typescript": "^6.0.3", - "vitest": "^4.1.0" + "vitest": "^4.1.7" }, "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc" } diff --git a/packages/embed-sdk/demo-my-invoices.html b/packages/embed-sdk/demo-my-invoices.html index 40b64e5..6f85dfe 100644 --- a/packages/embed-sdk/demo-my-invoices.html +++ b/packages/embed-sdk/demo-my-invoices.html @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>MPNext My Invoices Widget - Demo - +