Skip to content

feat(audience): date range segmentation for ESP date fields - #767

Draft
miguelpeixe wants to merge 32 commits into
mainfrom
claude/field-options-input-date-range-6694ea
Draft

feat(audience): date range segmentation for ESP date fields#767
miguelpeixe wants to merge 32 commits into
mainfrom
claude/field-options-input-date-range-6694ea

Conversation

@miguelpeixe

@miguelpeixe miguelpeixe commented Jul 29, 2026

Copy link
Copy Markdown
Member

All Submissions:

Changes proposed in this Pull Request:

Phase 2 of NPPD-2047, following #628. That PR gave every incoming ESP field a publisher-chosen segmentation operator, but left date / datetime fields with Text only — matching a date meant typing it exactly, which is useless for the case that motivated the ticket: "Latest Gift Date: January 1 2026 – Now".

A date-typed ESP field can now be segmented as a Date range.

In Audience → Connections → integration, a date field's "Segment as" dropdown offers Date range (the new default when you enable one) and Text. Fields already enabled as Text keep matching exactly as they do today until a publisher deliberately changes them.

In Audience → Campaigns → Segments, that criterion renders as a single dropdown:

  • Any — no constraint on the field
  • Last 7 days / Last 30 days / Last 365 days — rolling windows
  • Custom — reveals two rows, From and To

Each custom row is independently Any (unbounded on that end), a fixed Date, or Days ago / Days from now. A preset is a shortcut for a range the custom rows could build by hand — "Last 30 days" is exactly From: Days ago 30, To: Days ago 0 — so there is one stored format either way, and a range saved as a preset comes back as that preset rather than as Custom.

Relative bounds resolve at match time, so a rolling window stays current without anyone editing the segment each month — an absolute "since Jan 1" silently goes stale. Forward offsets cover expiry and renewal windows.

Matching is day-granular and inclusive at both ends. Reader values are normalized to ISO 8601 at pull time, using a source format each provider declares — 03/04/2026 is genuinely ambiguous between Mailchimp's two date_format settings, and the ESP's own metadata is only in hand at that point. The client matcher then validates a YYYY-MM-DD slice and compares lexicographically, reading the date part as written so a value the publisher sees as Jan 15 in ActiveCampaign matches on Jan 15 for every reader regardless of browser timezone.

A value that cannot be parsed with confidence is stored untouched, never guessed at. PHP's general parser only ever sees an already ISO-shaped value, and its result is only trusted when the date part survives a round trip — so 2026-02-30 stays verbatim rather than silently becoming 2026-03-02, and a Mailchimp value that doesn't fit its declared format is not re-read American-first. Both matchers reject anything that isn't a real calendar date, so an unparsable value matches nothing instead of matching wrongly. When that happens the pull logs the field and the source format it used — otherwise the only symptom is a segment that quietly matches nobody.

Both ESP providers declare their date metadata. ActiveCampaign's date / datetime fields and Mailchimp's date merge fields default to the date-range operator, and Mailchimp translates its per-field date_format. ActiveCampaign declares date_format explicitly as '' even though it always sends ISO: the key's presence is how a field snapshotted before source formats existed is told apart from one that genuinely has none, and the latter is refreshed from the live provider schema on read. Without that, a Mailchimp field enabled before this branch would have had no format to normalize with and matched nobody. Mailchimp birthday is corrected from date to string — it is MM/DD with no year, so it cannot sit on an absolute timeline and would have offered a range it could not honor.

Content-gate access rules have no range UI and still match a single date exactly, but they now compare calendar dates, with both sides put through the same normalizer. Selecting Date range for segmentation rewrites the stored value to ISO, so a gate rule written in the provider's own format (03/04/2026) would otherwise have silently stopped granting access to every reader it previously admitted — and the natural fix for that (switching the operator back, or disabling the field) unregisters the rule, which the content gate treats as "don't block", swinging the same gate wide open.

date_range is only emitted to a newspack-popups that can run it. newspack-popups now publishes the matching functions it supports, and newspack-plugin asks before registering the criterion, falling back to exact matching when the answer is no or the method is absent. An older build left an unresolvable name as a raw string and then called it, throwing a TypeError that escaped segment matching and aborted prompt display sitewide — and the fail-closed guard for that ships in newspack-popups, which is the plugin that would be stale, so it cannot be relied on for this skew.

No newspack-manager companion PR this time. An unset source format means ISO / Y-m-d, which is exactly what ActiveCampaign sends, so the managed AC integration needs no change.

Also fixed here

Latent problems this feature would otherwise have shipped into:

  • An unknown matching function no longer takes every prompt offline. newspack-popups left an unresolvable matching function as a raw string and then called it; it now fails closed.
  • Normalization is gated on the chosen operator. Rewriting stored values for a date field a publisher typed as Text would have broken their existing exact-match segments and content-gate rules within one pull cycle.
  • An emptied date range drops the criterion. Clearing both bounds used to leave value: {}, which the matcher reads as "no bounds to check" — matching every reader who had any date in that field.
  • An out-of-range relative offset fails closed. A large enough offset produced an invalid date whose string form compared as satisfied on the To end, widening the window to everyone.
  • The segment list renders date ranges ("30 days ago to today") instead of start: [object Object].
  • The date-range control no longer flips a loaded "Days from now" bound to "Days ago" when its value is cleared and retyped, no longer resets a cleared date to today mid-edit, and its value input has an accessible name.

Out of scope and known limits

  • Date range is offered only for ESP-typed date / datetime fields. A date living in a plain text field stays free text.
  • No recurring / year-agnostic matching (what a Mailchimp birthday would need).
  • A source format that changes after a field is enabled is not detected. The stored schema snapshot is refreshed only when the format is absent, so a publisher who switches a Mailchimp merge field from MM/DD/YYYY to DD/MM/YYYY gets confidently wrong dates until they re-save the Integrations screen. Closing this needs a periodic schema refresh, which is a broader change than belongs here.
  • The segment schema branch governs export/import and register_meta, not editor save. The wizard's segmentation routes register no args schema and write criteria through update_term_meta, so a malformed criterion still saves silently. This gap is pre-existing and identical for the shipped min / max branch.

Closes NPPD-2047.

How to test the changes in this Pull Request:

Automated — all green locally:

  1. newspack-plugin: n test-php (2357 tests, 6691 assertions) and n test-js (486)
  2. newspack-popups: n test-php (261 tests, 637 assertions) and n test-js (84)
  3. newspack-newsletters: n test-php (699 tests, 2941 assertions)
  4. packages/components: 95 tests

Manual — with an ActiveCampaign or Mailchimp connection:

  1. In Audience → Connections → integration, enable a date field. Confirm the operator dropdown offers Date range and Text, and that enabling it selects Date range.
  2. Confirm a date field that was already enabled as Text still reads Text and is not silently rewritten.
  3. In Audience → Campaigns → Segments, add that field as a criterion. Confirm a single dropdown offering Any / Last 7 days / Last 30 days / Last 365 days / Custom, defaulting to Any with no further inputs.
  4. Choose Last 30 days, save, reload. Confirm it comes back as Last 30 days, not as Custom.
  5. Switch to Custom. Confirm the two rows appear pre-filled with Days ago 30 / Days ago 0 — the preset's range, not a reset.
  6. Set From = Date and pick a fixed date. Confirm the native date input is usable at its rendered width and lines up with its selector. Save and reload.
  7. Regression for the bound-direction bug: on a saved segment holding a Days from now bound, clear the value and retype it. Confirm the selector still reads Days from now and the saved value is a positive offset.
  8. Set a configured range back to Any and save. Confirm the criterion is dropped from the segment rather than saved as an empty value.
  9. Confirm the segments list summarises the criterion in words ("30 days ago to today", "2026-01-01 to today").
  10. Confirm matching: a reader whose ESP date falls inside the window matches, one outside does not, and a reader whose value is empty or unparsable does not.
  11. Regression on existing behavior: a Number (range) criterion, a Multiple values (list__in) criterion, and a plain Text criterion all still render, save and match as before.
  12. Regression on content gates: an access rule on a date field still matches by exact date, including when the rule is written in the provider's own format.

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully run tests with your changes locally?

Manual verification was done in an isolated environment against a stand-in CRM integration registered through the public Integration API, rather than a live Mailchimp or ActiveCampaign account. That exercises the framework end to end — provider schema, operator selection, pull-time normalization, segment editor, segment matching and content-gate rules — including the ambiguous 03/04/2026 case under both Mailchimp date_format settings, an impossible calendar date, a value that doesn't fit its declared format, and a field stored without a source format. It does not exercise the real provider mappers against live API payloads; those remain covered by unit tests only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class date range segmentation for ESP-provided date/datetime fields across Newspack Audience, including provider-declared date format metadata to safely normalize stored reader values for reliable matching.

Changes:

  • Introduces a new date_range operator for incoming ESP date fields, plus UI to edit { start, end } bounds (absolute date or relative days).
  • Normalizes stored reader date values to ISO-8601 at pull time only when the publisher selected date_range, using provider-declared date_format.
  • Updates popups criteria matching to support date_range and hardens matching-function resolution to fail closed when unknown.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
plugins/newspack-popups/tests/test-schemas.php Adds schema test cases covering date-range criterion values.
plugins/newspack-popups/src/criteria/utils.js Fails closed when a matching function name can’t be resolved (prevents TypeError sitewide).
plugins/newspack-popups/src/criteria/matching-functions.test.js Adds unit coverage for date_range matching behavior and edge cases.
plugins/newspack-popups/src/criteria/matching-functions.js Implements date_range matcher and date parsing/validation helpers.
plugins/newspack-popups/src/criteria/index.test.js Adds regression test ensuring unresolved matchers never match (and don’t throw).
plugins/newspack-popups/includes/class-newspack-segments-model.php Extends REST meta schema to allow { start, end } date-range criterion values.
plugins/newspack-plugin/tests/unit-tests/integrations/class-test-promoted-fields.php Pins access-rule behavior: date fields still exact-match via access rules.
plugins/newspack-plugin/tests/unit-tests/integrations/class-test-integrations.php Adds coverage for storing date_range operator + date normalization behavior.
plugins/newspack-plugin/tests/unit-tests/integrations/class-test-esp.php Verifies date_format is applied from provider schema and defaults to empty.
plugins/newspack-plugin/src/wizards/audience/views/integrations/configure-view.test.js Updates operator constraints for date fields and adds regression for preserving Text.
plugins/newspack-plugin/src/wizards/audience/views/integrations/configure-view.js Offers Date range + Text operators for date/datetime value types.
plugins/newspack-plugin/src/wizards/audience/views/campaigns/segments/single-segment.test.js Adds rendering + editing tests for the date range segment UI, including regressions.
plugins/newspack-plugin/src/wizards/audience/views/campaigns/segments/single-segment.js Renders DateRangeSetting when a criterion uses date_range.
plugins/newspack-plugin/includes/reader-activation/integrations/README.md Documents new date_range operator and date_format field metadata.
plugins/newspack-plugin/includes/reader-activation/integrations/class-integration.php Allows date_range as a persisted matching function.
plugins/newspack-plugin/includes/reader-activation/integrations/class-incoming-field.php Adds date_format property + accessors to Incoming_Field.
plugins/newspack-plugin/includes/reader-activation/integrations/class-esp.php Applies provider-declared date_format onto Incoming_Field during configuration.
plugins/newspack-plugin/includes/reader-activation/integrations/class-contact-pull.php Normalizes date/datetime values to ISO on pull when date_range is selected.
plugins/newspack-newsletters/tests/test-mailchimp-integrations-schema.php Updates mapping expectations: Mailchimp datedate_range, birthday → string; asserts date_format.
plugins/newspack-newsletters/tests/test-active-campaign-integrations-schema.php Ensures AC date/datetime fields default to date_range.
plugins/newspack-newsletters/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php Maps Mailchimp date_format option to PHP format and emits date_range for date fields.
plugins/newspack-newsletters/includes/service-providers/class-newspack-newsletters-service-provider.php Updates provider schema docs for date_range and date_format.
plugins/newspack-newsletters/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign.php Defaults date/datetime fields to date_range operator.
packages/components/src/settings/style.scss Adds layout styles for the date range bound rows.
packages/components/src/settings/index.js Exports the new DateRangeSetting component.
packages/components/src/settings/DateRangeSetting.js Implements the shared date range control (From/To with Any/Date/Days ago/from now).

Comment thread plugins/newspack-popups/includes/class-newspack-segments-model.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

plugins/newspack-popups/tests/test-criteria.php:161

  • The regex assertion that checks SUPPORTED_MATCHING_FUNCTIONS exist in matching-functions.js is coupled to the file’s exact indentation (^\t). This makes the test fragile to formatting changes (e.g. if Prettier/ESLint changes indentation to spaces), even though the export would still be correct. Matching optional leading whitespace keeps the test focused on the presence of the key rather than formatting.
	public function test_supported_matching_functions_exist_in_js_module() {
		$source = file_get_contents( __DIR__ . '/../src/criteria/matching-functions.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents

		foreach ( Newspack_Popups_Criteria::SUPPORTED_MATCHING_FUNCTIONS as $matching_function ) {
			$this->assertMatchesRegularExpression(
				'/^\t' . preg_quote( $matching_function, '/' ) . ':\s/m',
				$source,
				"$matching_function is advertised as supported but not exported by matching-functions.js"
			);
		}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants