Skip to content

feat(surveys): show auto-close date for recurring surveys - #72955

Merged
phillram merged 8 commits into
masterfrom
posthog-code/surveys-recurring-auto-close-helper
Jul 24, 2026
Merged

feat(surveys): show auto-close date for recurring surveys#72955
phillram merged 8 commits into
masterfrom
posthog-code/surveys-recurring-auto-close-helper

Conversation

@phillram

@phillram phillram commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Surveys now auto-close once their completion conditions are met. For a recurring survey ("Repeat on a schedule") set to "Show 2 times every 30 days", the survey silently closes 60 days after it starts — but nothing in the editor tells you that. Users have no idea when their survey will stop collecting responses, which is confusing and unintuitive. Raised in a Slack thread; the auto-close behavior itself landed in #68735.

Changes

Adds a helper line inside the completion conditions, right under the "Repeat on a schedule" option where the iteration count/frequency are configured:

  • If the survey has a start date, it states the exact date the survey will automatically close (e.g. "This survey will automatically close on March 2, 2026, after its last repeat").
  • If the survey hasn't launched yet, it states how long it will run instead (e.g. "Once launched, this survey will run for 60 days before it automatically closes").

The close date is computed as iteration_count * iteration_frequency_days days from the launch date, mirroring the backend logic in posthog/tasks/update_survey_iteration.py (the last iteration starts on start_date + (count - 1) * frequency and lasts frequency more days).

The date sits in the completion conditions section, next to the inputs that control it, so the consequence of changing the count/frequency is visible right where you edit them.

  • The close date is shown in three different locations:
  • The editor:
    • If the survey is brand new, it will show a count of days for the survey's lifecycle:
CleanShot 2026-07-22 at 14 46 33@2x
- If the survey is already running, it will provide an actual date:
CleanShot 2026-07-22 at 14 44 16
  • The overview section:
    • If the survey is brand new, it will show a count of days for the survey's lifecycle:
CleanShot 2026-07-22 at 14 48 01@2x
  • If the survey is already running, it will provide an actual date:
CleanShot 2026-07-22 at 14 48 34@2x

How did you test this code?

Added a unit test for the new getRecurringSurveyScheduleInfo helper in utils.test.ts, covering the total-duration calculation, the auto-close date derived from a start date, and the null cases (zero/null count or frequency). It catches a regression where the displayed close date drifts from the backend's count * frequency formula.

Tested it locally and added screenshots to this PR.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with the PostHog Slack app (Claude), directed by @phillram. The change was scoped to the completion conditions area of the classic survey editor (SurveyRepeatSchedule), since that's where the schedule is configured and where the confusion surfaces. The date computation was factored into a small pure helper (getRecurringSurveyScheduleInfo) rather than inlined, so it could be unit-tested against the backend's formula. Skill invoked: /writing-tests.


Created with PostHog from a Slack thread

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Bundle size — 🔺 +294 B (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 64.38 MiB · 🔺 +294 B (+0.0%)

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.24 MiB · 22 files no change ███░░░░░░░ 27.5% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.06 MiB · 3,009 files 🔺 +306 B (+0.0%) ████████░░ 83.0% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
281.5 KiB ../node_modules/.pnpm/posthog-js@1.407.2/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
236.0 KiB src/taxonomy/core-filter-definitions-by-group.json
226.1 KiB ../node_modules/.pnpm/posthog-js@1.407.2/node_modules/posthog-js/dist/module.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.8 KiB src/lib/api.ts
94.0 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Toolbar bundle — eager 2.18 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.18 MiB · 17 files no change ████░░░░░░ 38.1% of 5.72 MiB
Deferred (lazy) 2.07 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
713.8 KiB dist/toolbar/toolbar-app-HLTHZTTF.css
545.0 KiB dist/toolbar/chunk-chunk-JGF7ZYC3.js
484.2 KiB dist/toolbar/chunk-chunk-YMFODIKP.js
133.6 KiB dist/toolbar/chunk-chunk-YSVTUFEA.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.0 KiB dist/toolbar/toolbar-app-Y3ZKEAFE.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-ALQOU6WO.js
20.9 KiB dist/toolbar/chunk-chunk-VMBXRLPQ.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +18.6 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1352.14 MiB · 🔺 +18.6 KiB (+0.0%)

Playwright — all passed

All tests passed.

View test results →

@phillram phillram added the reviewhog ($$$) Reviews pull requests before humans do label Jul 22, 2026
@posthog

posthog Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 2 consider.

Published 3 findings (view the review).

@posthog

posthog Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot 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.

ReviewHog Report

Changes

Issues: 3 issues

Files (4)
  • frontend/src/scenes/surveys/SurveyOverview.tsx
  • frontend/src/scenes/surveys/SurveyRepeatSchedule.tsx
  • frontend/src/scenes/surveys/utils.ts
  • frontend/src/scenes/surveys/wizard/steps/SuccessStep.tsx

Comment on lines +567 to +577
export function getRecurringSurveyScheduleInfo(
survey: Pick<Survey, 'iteration_count' | 'iteration_frequency_days' | 'start_date'>
): RecurringSurveyScheduleInfo | null {
const count = survey.iteration_count
const frequency = survey.iteration_frequency_days
if (!count || !frequency || count < 1 || frequency < 1) {
return null
}
const totalDurationDays = count * frequency
const autoCloseDate = survey.start_date ? dayjs(survey.start_date).add(totalDurationDays, 'day') : null
return { totalDurationDays, autoCloseDate }

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.

Auto-close schedule info ignores survey.end_date, so already-stopped recurring surveys show a stale/contradictory 'closes on X' message

should_fix bug

Why we think it's a valid issue
  • Checked: The actual PR diff (gh pr diff 72955) since the helper isn't in the local checkout; the new getRecurringSurveyScheduleInfo in utils.ts, its three call sites (SurveyOverview.tsx, SurveyRepeatSchedule.tsx, SuccessStep.tsx), and how end_date/iteration fields are set on stop/archive in surveyLogic.tsx.
  • Found: The helper computes autoCloseDate = dayjs(survey.start_date).add(count*frequency, 'day') with no reference to survey.end_date. Both display call sites guard only on autoCloseDate.isBefore(dayjs()) — this catches 'computed date already passed' but NOT 'survey stopped early'. stopSurvey (surveyLogic.tsx:1714) sets end_date: dayjs().toISOString() and archiveSurvey (surveyLogic.tsx:2047-2053) sets end_date while running; neither clears iteration_count/iteration_frequency_days, so the helper still fires.
  • Found: Concrete failure — recurring survey starts 2026-01-01, count 2 × freq 30 → computed close 2026-03-02; user stops it 2026-01-15. Since 2026-03-02 is still in the future, the guard is false and the banner renders 'Automatically closes on March 2, 2026'. In SurveyOverview.tsx this sits in the same panel that renders the real 'End date' (lines 145-147) right above the 'Schedule' (152-153), so the overview shows an End date in the past and a future auto-close date for the same already-stopped survey.
  • Impact: Reachable and realistic (stopping/archiving a recurring survey early is routine) and introduced by this PR. The result is self-contradictory, misleading info on the overview and editor — undermining the exact 'when will my survey close' clarity this PR set out to provide. Meets the correctness-bug bar; the author's isBefore(dayjs()) handling shows the ended state was considered but keyed off the wrong signal (computed date vs end_date). should_fix is right — no functional/data breakage, but a user-visible contradiction on a common flow.
Issue description

getRecurringSurveyScheduleInfo() derives autoCloseDate purely from survey.start_date + (iteration_count * iteration_frequency_days), without ever checking survey.end_date. A recurring survey's iteration settings (iteration_count, iteration_frequency_days) stay populated even after the survey has already stopped — via a manual 'stop' action (surveyLogic.tsx stopSurvey → api.surveys.update(props.id, { end_date: dayjs().toISOString() })), via archiveSurvey setting end_date while running, or after the backend's own auto-close task (_has_final_iteration_ended in posthog/tasks/update_survey_iteration.py) has already set end_date. In every one of those cases this helper still returns a computed autoCloseDate as if the survey were still running toward that date. This function is consumed by SurveyOverview.tsx (SurveySchedule, lines 27-48) and SurveyRepeatSchedule.tsx (SurveyAutoCloseHelper, lines 93-130), so a survey that has already been manually stopped (end_date in the past, possibly long before the computed date) will simultaneously show its real 'End date' in SurveyOverview and an unrelated, often future-dated 'Automatically closes on ' banner for the same survey — actively misleading, since the survey is already inactive.

Suggested fix

Include end_date in the Pick<> the helper accepts and short-circuit when it's set, e.g. if (survey.end_date) { return null } before computing totalDurationDays/autoCloseDate (or return a distinct alreadyEnded state so callers can render the actual end_date instead of a projected one). Add end_date to the Pick<Survey, ...> signature and to the two call sites' props so TypeScript enforces passing it through.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/surveys/utils.ts#L567-577

<issue_description>
getRecurringSurveyScheduleInfo() derives autoCloseDate purely from survey.start_date + (iteration_count * iteration_frequency_days), without ever checking survey.end_date. A recurring survey's iteration settings (iteration_count, iteration_frequency_days) stay populated even after the survey has already stopped — via a manual 'stop' action (surveyLogic.tsx stopSurvey → api.surveys.update(props.id, { end_date: dayjs().toISOString() })), via archiveSurvey setting end_date while running, or after the backend's own auto-close task (_has_final_iteration_ended in posthog/tasks/update_survey_iteration.py) has already set end_date. In every one of those cases this helper still returns a computed autoCloseDate as if the survey were still running toward that date. This function is consumed by SurveyOverview.tsx (SurveySchedule, lines 27-48) and SurveyRepeatSchedule.tsx (SurveyAutoCloseHelper, lines 93-130), so a survey that has already been manually stopped (end_date in the past, possibly long before the computed date) will simultaneously show its real 'End date' in SurveyOverview and an unrelated, often future-dated 'Automatically closes on <date>' banner for the same survey — actively misleading, since the survey is already inactive.
</issue_description>

<issue_validation>
- **Checked:** The actual PR diff (`gh pr diff 72955`) since the helper isn't in the local checkout; the new `getRecurringSurveyScheduleInfo` in `utils.ts`, its three call sites (`SurveyOverview.tsx`, `SurveyRepeatSchedule.tsx`, `SuccessStep.tsx`), and how `end_date`/iteration fields are set on stop/archive in `surveyLogic.tsx`.
- **Found:** The helper computes `autoCloseDate = dayjs(survey.start_date).add(count*frequency, 'day')` with no reference to `survey.end_date`. Both display call sites guard only on `autoCloseDate.isBefore(dayjs())` — this catches 'computed date already passed' but NOT 'survey stopped early'. `stopSurvey` (surveyLogic.tsx:1714) sets `end_date: dayjs().toISOString()` and `archiveSurvey` (surveyLogic.tsx:2047-2053) sets `end_date` while running; neither clears `iteration_count`/`iteration_frequency_days`, so the helper still fires.
- **Found:** Concrete failure — recurring survey starts 2026-01-01, count 2 × freq 30 → computed close 2026-03-02; user stops it 2026-01-15. Since 2026-03-02 is still in the future, the guard is false and the banner renders 'Automatically closes on March 2, 2026'. In `SurveyOverview.tsx` this sits in the same panel that renders the real 'End date' (lines 145-147) right above the 'Schedule' (152-153), so the overview shows an End date in the past and a future auto-close date for the same already-stopped survey.
- **Impact:** Reachable and realistic (stopping/archiving a recurring survey early is routine) and introduced by this PR. The result is self-contradictory, misleading info on the overview and editor — undermining the exact 'when will my survey close' clarity this PR set out to provide. Meets the correctness-bug bar; the author's `isBefore(dayjs())` handling shows the ended state was considered but keyed off the wrong signal (computed date vs `end_date`). should_fix is right — no functional/data breakage, but a user-visible contradiction on a common flow.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Include end_date in the Pick<> the helper accepts and short-circuit when it's set, e.g. `if (survey.end_date) { return null }` before computing totalDurationDays/autoCloseDate (or return a distinct `alreadyEnded` state so callers can render the actual end_date instead of a projected one). Add `end_date` to the Pick<Survey, ...> signature and to the two call sites' props so TypeScript enforces passing it through.
</potential_solution>

Comment thread frontend/src/scenes/surveys/utils.ts Outdated
Comment on lines +561 to +576
/**
* A recurring survey ("Repeat on a schedule") auto-closes once its final iteration window has passed.
* The last iteration starts on `start_date + (count - 1) * frequency` days and lasts `frequency` more days,
* so the survey runs for `count * frequency` days total and closes at the end of that span.
* Mirrors the backend logic in posthog/tasks/update_survey_iteration.py.
*/
export function getRecurringSurveyScheduleInfo(
survey: Pick<Survey, 'iteration_count' | 'iteration_frequency_days' | 'start_date'>
): RecurringSurveyScheduleInfo | null {
const count = survey.iteration_count
const frequency = survey.iteration_frequency_days
if (!count || !frequency || count < 1 || frequency < 1) {
return null
}
const totalDurationDays = count * frequency
const autoCloseDate = survey.start_date ? dayjs(survey.start_date).add(totalDurationDays, 'day') : null

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.

Displayed auto-close date is the last active day, not the day the survey actually closes — off by at least one day versus the backend

consider bug

Why we think it's a valid issue
  • Checked: Backend iteration-date generation (products/surveys/backend/models.py:455-462), the auto-close task (posthog/tasks/update_survey_iteration.py:42,46), its cron cadence (posthog/tasks/scheduled.py:657-661), and the frontend arithmetic + copy in the PR diff.
  • Found: iteration_start_dates come from rrule(DAILY, count, interval=frequency, dtstart=start_date), so last_iteration_start = start_date + (count-1)*frequency and final_iteration_end = last_iteration_start + frequency = start_date + count*frequency. The frontend's autoCloseDate = dayjs(start_date).add(count*frequency, 'day') equals final_iteration_end exactly. The task closes only when date.today() > final_iteration_end (strict) and runs every 12h, so closure lands the day after final_iteration_end. The premise is technically correct: the displayed date is the last active day, and the survey actually stops ~1 day later.
  • Impact: Real but negligible. The discrepancy is a single day, in the harmless direction (the survey collects slightly longer than the copy implies, never shorter) — no data loss, no functional break, no meaningfully misleading state. 'Closes on the last active day' is a defensible reading of an end date, and the only fix is a one-word copy nuance ('through' vs 'on'). Far below should_fix; not a correctness bug that harms users.
  • Priority: Down-ranked to consider — the observation is accurate and reproducible on a common path (every recurring survey display), so it stays on record, but a one-day, safe-direction imprecision on an informational date doesn't warrant should_fix.
Issue description

The JSDoc claims this mirrors posthog/tasks/update_survey_iteration.py, and the arithmetic does match final_iteration_end there (last_iteration_start.date() + timedelta(days=frequency), which equals start_date + count*frequency days). But that backend variable is the last day the survey is still open — the task only closes the survey when date.today() > final_iteration_end (strict greater-than), i.e. actual closure happens starting the day after final_iteration_end, whenever the periodic task next runs. The new UI text (SurveyOverview.tsx 'Automatically closes on ' / SurveyRepeatSchedule.tsx 'This survey will automatically close on , after its last repeat') states the survey closes ON autoCloseDate, but per the backend it is still actively collecting responses through that entire day and only stops afterward. For the PR's own worked example (2 iterations of 30 days from a March 1 start), the helper reports April 30 as the close date, while the backend keeps the survey open through April 30 and only sets end_date starting May 1 (or later, depending on task cadence).

Suggested fix

Either add one day when presenting the user-facing close date (autoCloseDate.add(1, 'day') for display, while keeping the raw value for isBefore/'has it ended' checks), or adjust the copy to describe the last active day instead of the close day, e.g. 'will keep collecting responses through ' rather than 'will automatically close on '.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/surveys/utils.ts#L561-576

<issue_description>
The JSDoc claims this mirrors posthog/tasks/update_survey_iteration.py, and the arithmetic does match `final_iteration_end` there (last_iteration_start.date() + timedelta(days=frequency), which equals start_date + count*frequency days). But that backend variable is the last day the survey is still open — the task only closes the survey when `date.today() > final_iteration_end` (strict greater-than), i.e. actual closure happens starting the day *after* `final_iteration_end`, whenever the periodic task next runs. The new UI text (SurveyOverview.tsx 'Automatically closes on <date>' / SurveyRepeatSchedule.tsx 'This survey will automatically close on <date>, after its last repeat') states the survey closes ON autoCloseDate, but per the backend it is still actively collecting responses through that entire day and only stops afterward. For the PR's own worked example (2 iterations of 30 days from a March 1 start), the helper reports April 30 as the close date, while the backend keeps the survey open through April 30 and only sets end_date starting May 1 (or later, depending on task cadence).
</issue_description>

<issue_validation>
- **Checked:** Backend iteration-date generation (`products/surveys/backend/models.py:455-462`), the auto-close task (`posthog/tasks/update_survey_iteration.py:42,46`), its cron cadence (`posthog/tasks/scheduled.py:657-661`), and the frontend arithmetic + copy in the PR diff.
- **Found:** `iteration_start_dates` come from `rrule(DAILY, count, interval=frequency, dtstart=start_date)`, so `last_iteration_start = start_date + (count-1)*frequency` and `final_iteration_end = last_iteration_start + frequency = start_date + count*frequency`. The frontend's `autoCloseDate = dayjs(start_date).add(count*frequency, 'day')` equals `final_iteration_end` exactly. The task closes only when `date.today() > final_iteration_end` (strict) and runs every 12h, so closure lands the day after `final_iteration_end`. The premise is technically correct: the displayed date is the last active day, and the survey actually stops ~1 day later.
- **Impact:** Real but negligible. The discrepancy is a single day, in the harmless direction (the survey collects slightly longer than the copy implies, never shorter) — no data loss, no functional break, no meaningfully misleading state. 'Closes on the last active day' is a defensible reading of an end date, and the only fix is a one-word copy nuance ('through' vs 'on'). Far below should_fix; not a correctness bug that harms users.
- **Priority:** Down-ranked to `consider` — the observation is accurate and reproducible on a common path (every recurring survey display), so it stays on record, but a one-day, safe-direction imprecision on an informational date doesn't warrant should_fix.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Either add one day when presenting the user-facing close date (`autoCloseDate.add(1, 'day')` for display, while keeping the raw value for isBefore/'has it ended' checks), or adjust the copy to describe the last active day instead of the close day, e.g. 'will keep collecting responses through <date>' rather than 'will automatically close on <date>'.
</potential_solution>

Comment on lines +570 to +577
const count = survey.iteration_count
const frequency = survey.iteration_frequency_days
if (!count || !frequency || count < 1 || frequency < 1) {
return null
}
const totalDurationDays = count * frequency
const autoCloseDate = survey.start_date ? dayjs(survey.start_date).add(totalDurationDays, 'day') : null
return { totalDurationDays, autoCloseDate }

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.

getRecurringSurveyScheduleInfo computes the auto-close date in the browser's local timezone, but the backend computes it in UTC calendar days

consider bug

Why we think it's a valid issue
  • Checked: lib/dayjs.ts (utc plugin extended at line 29; default dayjs(iso) parses to local mode), the backend's UTC date semantics in _has_final_iteration_ended (posthog/tasks/update_survey_iteration.py:42, last_iteration_start.date()), and how start_date gets its value.
  • Found: The premise holds technically. dayjs(survey.start_date) yields a local-mode object, so .add(N, 'day') and .format(...) operate in the browser's timezone, while the backend computes on the UTC calendar date. Max divergence is exactly one calendar day, and only when start_date's UTC time-of-day lands within the browser's offset of UTC midnight (e.g. a US-Pacific viewer sees the prior day for start times 00:00–08:00 UTC). start_date is the launch instant (dayjs().toISOString()), so that time-of-day is arbitrary — the trigger is reachable for a meaningful slice of non-UTC users, not a rare boundary.
  • Impact: Real but minor — a single-calendar-day, viewer-timezone-dependent wobble on a projected informational close date. Same one-day impact class as sibling finding 2-1-2, and the actual close is already non-deterministic (auto-close task runs every 12h with a strict > comparison), so the display is inherently an estimate. No functional or data impact; a user's actions don't change because the projection reads a day off. It is a genuine correctness defect (wrong TZ tool, bypassing the file's own dayjs.utc()/TZLabel patterns) with a trivial one-line fix, so it stays on record — but the consequence doesn't justify should_fix.
  • Priority: Down-ranked to consider, consistent with the calibration of sibling one-day informational-date discrepancy 2-1-2; keeps it on record without surfacing at should_fix urgency.
Issue description

dayjs(survey.start_date) (survey.start_date is a Django DateTimeField stored/serialized in UTC, per products/surveys/backend/models.py:230 and TIME_ZONE = "UTC" / USE_TZ = True in posthog/settings/web.py) is parsed and manipulated with plain dayjs, which — unlike the dayjs.utc() mode already available via dayjs.extend(utc) in lib/dayjs.ts — operates in the browser's local timezone for .add() and .format(). Meanwhile the backend arithmetic this helper claims to mirror (posthog/tasks/update_survey_iteration.py, _has_final_iteration_ended) works entirely off last_iteration_start.date(), i.e. the UTC calendar date of the same stored datetime, and date.today() (server, UTC). For any survey whose start_date time-of-day falls close to a UTC midnight boundary, a browser far from UTC (e.g. US Pacific, or anywhere with a large positive/negative offset) will compute a different calendar date for start_date than the backend does, so totalDurationDays days later lands on a different calendar day too — the displayed close date silently disagrees with the backend's actual close date by a full day, independent of the (already-flagged) off-by-one from the backend's strict > comparison. Notably, SurveyOverview.tsx (unchanged, line 142/147) already renders this exact same start_date/end_date field via the timezone-aware <TZLabel time={...} /> component elsewhere on the same page — the new code bypasses that established, timezone-safe pattern for the identical field.

Suggested fix

Do the date arithmetic in UTC to match the backend's semantics, e.g. dayjs.utc(survey.start_date).add(totalDurationDays, 'day'), and either format with .format(...) after .local() conversion for display or keep formatting in UTC to stay consistent with how the backend decides the day. At minimum, document that both sides must agree on which timezone 'the day' is computed in, and add a unit test with a start_date near a UTC day boundary from a non-UTC-simulated locale to catch regressions.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/surveys/utils.ts#L570-577

<issue_description>
`dayjs(survey.start_date)` (survey.start_date is a Django `DateTimeField` stored/serialized in UTC, per `products/surveys/backend/models.py:230` and `TIME_ZONE = "UTC"` / `USE_TZ = True` in `posthog/settings/web.py`) is parsed and manipulated with plain `dayjs`, which — unlike the `dayjs.utc()` mode already available via `dayjs.extend(utc)` in `lib/dayjs.ts` — operates in the browser's local timezone for `.add()` and `.format()`. Meanwhile the backend arithmetic this helper claims to mirror (`posthog/tasks/update_survey_iteration.py`, `_has_final_iteration_ended`) works entirely off `last_iteration_start.date()`, i.e. the UTC calendar date of the same stored datetime, and `date.today()` (server, UTC). For any survey whose `start_date` time-of-day falls close to a UTC midnight boundary, a browser far from UTC (e.g. US Pacific, or anywhere with a large positive/negative offset) will compute a different calendar date for `start_date` than the backend does, so `totalDurationDays` days later lands on a different calendar day too — the displayed close date silently disagrees with the backend's actual close date by a full day, independent of the (already-flagged) off-by-one from the backend's strict `>` comparison. Notably, `SurveyOverview.tsx` (unchanged, line 142/147) already renders this exact same `start_date`/`end_date` field via the timezone-aware `<TZLabel time={...} />` component elsewhere on the same page — the new code bypasses that established, timezone-safe pattern for the identical field.
</issue_description>

<issue_validation>
- **Checked:** `lib/dayjs.ts` (utc plugin extended at line 29; default `dayjs(iso)` parses to local mode), the backend's UTC date semantics in `_has_final_iteration_ended` (`posthog/tasks/update_survey_iteration.py:42`, `last_iteration_start.date()`), and how `start_date` gets its value.
- **Found:** The premise holds technically. `dayjs(survey.start_date)` yields a local-mode object, so `.add(N, 'day')` and `.format(...)` operate in the browser's timezone, while the backend computes on the UTC calendar date. Max divergence is exactly one calendar day, and only when `start_date`'s UTC time-of-day lands within the browser's offset of UTC midnight (e.g. a US-Pacific viewer sees the prior day for start times 00:00–08:00 UTC). `start_date` is the launch instant (`dayjs().toISOString()`), so that time-of-day is arbitrary — the trigger is reachable for a meaningful slice of non-UTC users, not a rare boundary.
- **Impact:** Real but minor — a single-calendar-day, viewer-timezone-dependent wobble on a *projected informational* close date. Same one-day impact class as sibling finding 2-1-2, and the actual close is already non-deterministic (auto-close task runs every 12h with a strict `>` comparison), so the display is inherently an estimate. No functional or data impact; a user's actions don't change because the projection reads a day off. It is a genuine correctness defect (wrong TZ tool, bypassing the file's own `dayjs.utc()`/`TZLabel` patterns) with a trivial one-line fix, so it stays on record — but the consequence doesn't justify should_fix.
- **Priority:** Down-ranked to `consider`, consistent with the calibration of sibling one-day informational-date discrepancy 2-1-2; keeps it on record without surfacing at should_fix urgency.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Do the date arithmetic in UTC to match the backend's semantics, e.g. `dayjs.utc(survey.start_date).add(totalDurationDays, 'day')`, and either format with `.format(...)` after `.local()` conversion for display or keep formatting in UTC to stay consistent with how the backend decides the day. At minimum, document that both sides must agree on which timezone 'the day' is computed in, and add a unit test with a `start_date` near a UTC day boundary from a non-UTC-simulated locale to catch regressions.
</potential_solution>

@phillram
phillram marked this pull request as ready for review July 22, 2026 22:41
@phillram
phillram requested a review from a team July 22, 2026 22:42
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
🛠️ Admin inspect & debug state in hogland
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit dbddbe1 · box box-60b8524f5c15 · ready in 835s (push → usable) · build log · rebuilds on every push, torn down on close

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
frontend/src/scenes/surveys/utils.ts:580
**Projected Close Date Is Early**

The backend leaves the survey open while `date.today()` equals the final iteration end date and closes it only when the date is later. This helper displays that end date as the automatic close date, so a survey shown as closing on March 2 remains open through March 2 and closes on a later task run.

### Issue 2 of 4
frontend/src/scenes/surveys/utils.ts:579-580
**Iteration Cap Changes Close Date**

When `iteration_count` exceeds 500, the backend generates only 500 iteration start dates, but this helper projects the close date using the full count. An API-created survey with 1,000 iterations therefore displays a close date based on 1,000 windows even though the backend evaluates its final window at iteration 500.

### Issue 3 of 4
frontend/src/scenes/surveys/SurveyViewRedesign/SurveySidebar.tsx:85
**Stale Fields Imply Recurrence**

This call does not verify that `survey.schedule` is recurring. A non-recurring survey that retains nonzero iteration fields from an older or API-driven schedule change receives schedule information and the sidebar claims it will auto-close, even though the selected Once or Always schedule does not have that lifecycle.

### Issue 4 of 4
frontend/src/scenes/surveys/wizard/steps/SuccessStep.tsx:22
**Every-Event Check Is Too Broad**

Not repeating on every event does not mean the survey uses scheduled recurrence. If a Once or Always survey retains nonzero iteration fields, this condition computes schedule information and the success screen reports an automatic close that does not apply to its selected schedule.

```suggestion
    const scheduleInfo =
        survey.schedule === SurveySchedule.Recurring && !repeatsOnEveryEvent
            ? getRecurringSurveyScheduleInfo(survey)
            : null
```

Reviews (1): Last reviewed commit: "feat(surveys): show auto-close date in r..." | Re-trigger Greptile

return null
}
const totalDurationDays = count * frequency
const autoCloseDate = survey.start_date ? dayjs.utc(survey.start_date).add(totalDurationDays, 'day') : null

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.

P1 Projected Close Date Is Early

The backend leaves the survey open while date.today() equals the final iteration end date and closes it only when the date is later. This helper displays that end date as the automatic close date, so a survey shown as closing on March 2 remains open through March 2 and closes on a later task run.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/surveys/utils.ts
Line: 580

Comment:
**Projected Close Date Is Early**

The backend leaves the survey open while `date.today()` equals the final iteration end date and closes it only when the date is later. This helper displays that end date as the automatic close date, so a survey shown as closing on March 2 remains open through March 2 and closes on a later task run.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread frontend/src/scenes/surveys/utils.ts Outdated
const statusLabel = !survey.start_date ? 'Draft' : survey.end_date ? 'Complete' : 'Running'
const conditionsSummary = hasTargetingSet ? getSurveyDisplayConditionsSummary(survey as Survey) : []
const collectionLimitSummary = getSurveyCollectionLimitSummary(survey)
const scheduleInfo = getRecurringSurveyScheduleInfo(survey)

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.

P2 Stale Fields Imply Recurrence

This call does not verify that survey.schedule is recurring. A non-recurring survey that retains nonzero iteration fields from an older or API-driven schedule change receives schedule information and the sidebar claims it will auto-close, even though the selected Once or Always schedule does not have that lifecycle.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/surveys/SurveyViewRedesign/SurveySidebar.tsx
Line: 85

Comment:
**Stale Fields Imply Recurrence**

This call does not verify that `survey.schedule` is recurring. A non-recurring survey that retains nonzero iteration fields from an older or API-driven schedule change receives schedule information and the sidebar claims it will auto-close, even though the selected Once or Always schedule does not have that lifecycle.

How can I resolve this? If you propose a fix, please make it concise.

export function SuccessStep({ survey }: SuccessStepProps): JSX.Element {
const isHostedSurvey = survey.type === SurveyType.ExternalSurvey
const repeatsOnEveryEvent = doesSurveyRepeatOnEveryEvent(survey)
const scheduleInfo = !repeatsOnEveryEvent ? getRecurringSurveyScheduleInfo(survey) : null

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.

P2 Every-Event Check Is Too Broad

Not repeating on every event does not mean the survey uses scheduled recurrence. If a Once or Always survey retains nonzero iteration fields, this condition computes schedule information and the success screen reports an automatic close that does not apply to its selected schedule.

Suggested change
const scheduleInfo = !repeatsOnEveryEvent ? getRecurringSurveyScheduleInfo(survey) : null
const scheduleInfo =
survey.schedule === SurveySchedule.Recurring && !repeatsOnEveryEvent
? getRecurringSurveyScheduleInfo(survey)
: null
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/surveys/wizard/steps/SuccessStep.tsx
Line: 22

Comment:
**Every-Event Check Is Too Broad**

Not repeating on every event does not mean the survey uses scheduled recurrence. If a Once or Always survey retains nonzero iteration fields, this condition computes schedule information and the success screen reports an automatic close that does not apply to its selected schedule.

```suggestion
    const scheduleInfo =
        survey.schedule === SurveySchedule.Recurring && !repeatsOnEveryEvent
            ? getRecurringSurveyScheduleInfo(survey)
            : null
```

How can I resolve this? If you propose a fix, please make it concise.

@lucasheriques
lucasheriques enabled auto-merge (squash) July 23, 2026 17:20
@lucasheriques
lucasheriques disabled auto-merge July 23, 2026 17:20
@lucasheriques lucasheriques added the stamphog Request AI approval (no full review) label Jul 23, 2026
@stamphog

stamphog Bot commented Jul 23, 2026

Copy link
Copy Markdown

Note

🤖 stamphog reviewed eec4cb726132316663c0ef74ea2ed6abe585e386 — verdict: REFUSED

This is a low-risk additive frontend display feature (not risky territory), and two of the bot reviewers' concerns were already fixed in the diff (missing end_date check, UTC arithmetic) — but Greptile's still-unresolved findings that non-recurring surveys with leftover iteration fields will wrongly show an 'auto-closes' message, and that the backend's 500-iteration cap isn't accounted for, are genuine, reproducible correctness bugs left unaddressed on the current head.

  • 👍 on the PR from greptile-apps[bot], hex-security-app[bot].
  • Unresolved Greptile comment on SurveyViewRedesign/SurveySidebar.tsx: getRecurringSurveyScheduleInfo is called without checking survey.schedule === Recurring, so a non-recurring survey with stale iteration_count/iteration_frequency_days values would incorrectly display an auto-close date.
  • Unresolved Greptile comment on wizard/steps/SuccessStep.tsx: the schedule-info check only guards on !repeatsOnEveryEvent, not on survey.schedule === Recurring, same stale-field risk as above.
  • Unresolved Greptile comment on utils.ts: the helper projects the close date using the full iteration_count even though the backend caps generated iteration windows at 500, so surveys with >500 iterations show a materially wrong close date.
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 133L, 5F substantive, 179L/6F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1c-medium (179L, 6F, single-area, feat)
stamphog 2.0.0b3 .stamphog/policy.yml @ 9922cca · reviewed head eec4cb7

@posthog

posthog Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Visual changes approved by @phillram — baseline updated in c6a612a.

View this run in PostHog

2 changed.

@posthog-bot-comment-resolver

posthog-bot-comment-resolver Bot commented Jul 23, 2026

Copy link
Copy Markdown

🔀 Tried to auto-resolve conflicts with master but this one needs a human.

I won't retry until the branch or master moves.

phillram added 5 commits July 23, 2026 16:17
Recurring surveys ("Repeat on a schedule") automatically close once their
final iteration window passes, but nothing in the editor made that date
visible. Surface it directly under the completion conditions: when the
survey has a launch date, state the exact date it will close; before launch,
state how many days it will run for.

Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
Surface the recurring-survey auto-close date in two more places: the survey
overview "Schedule" row and the creation wizard's success summary, so the
close date is visible both when viewing a running survey and right after
creating one.

Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
A recurring survey launched long enough ago that its final iteration window
already elapsed (e.g. started in 2023 with a 30-day window) previously showed
a future-tense "will automatically close on <past date>" message. Detect the
past-date case and explain that the survey closes on the next scheduled check
(within 12 hours) instead.

Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
Address review feedback on the recurring-survey auto-close helper:

- Return null when the survey already has an end_date, so a stopped or
  archived recurring survey no longer shows a projected "closes on X" banner
  that contradicts its real end date.
- Compute the close date in UTC to match the backend's calendar-day math in
  update_survey_iteration.py, rather than the browser's local timezone.

Also rewraps the editor helper copy to satisfy oxfmt.

Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
phillram added 2 commits July 23, 2026 16:17
The redesigned survey view (behind the surveys-redesigned-view flag) uses a
sidebar instead of the legacy Overview tab, so it wasn't showing the recurring
auto-close date. Add an "Auto-closes" row to the sidebar's "At a glance"
section so the close date appears regardless of which view a user is on.

Generated-By: PostHog Code
Task-Id: f9eadc61-ea59-4e7c-974e-ae11ef8cab26
The recurring auto-close helper is now the single source of truth for
whether a survey shows a projected close date. It only returns schedule
info when the survey's schedule is actually Recurring, so a non-recurring
survey carrying leftover iteration_count/iteration_frequency_days values
no longer wrongly shows an "Auto-closes" date in the sidebar or wizard
success screen. It also clamps the iteration count to the backend cap
(MAX_ITERATION_COUNT), so surveys with more iterations than the backend
generates windows for no longer project a close date past the real one.

Deduplicates MAX_ITERATION_COUNT into the shared surveys constants.

Generated-By: PostHog Code
Task-Id: 8bb86ec7-fdb9-44fd-9974-20d30f643f9d

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Purely additive, informational UI text (no data model, API, auth, or CI changes); the bot review concerns (end_date, timezone, iteration cap, non-recurring surveys) were raised on older commits and are already fixed in the current diff with matching unit tests, leaving only a negligible one-day informational rounding noted by the reviewer itself as harmless.

  • 👍 on the PR from greptile-apps[bot], hex-security-app[bot].
  • Minor: displayed auto-close date is the survey's last active day, ~1 day before the backend actually closes it (harmless direction, already flagged as negligible by reviewhog) — not blocking.
  • Branch reportedly has a merge conflict with master per posthog-bot-comment-resolver; will need a manual rebase before merge, independent of this review.
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 157L, 7F substantive, 266L/8F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1c-medium (266L, 8F, single-area, feat)
stamphog 2.0.0b3 .stamphog/policy.yml @ 62ae082 · reviewed head dbddbe1

@phillram
phillram merged commit 0eb0aea into master Jul 24, 2026
293 checks passed
@phillram
phillram deleted the posthog-code/surveys-recurring-auto-close-helper branch July 24, 2026 15:32
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-24 15:57 UTC Run
prod-us ✅ Deployed 2026-07-24 16:13 UTC Run
prod-eu ✅ Deployed 2026-07-24 16:17 UTC Run

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

Labels

reviewhog ($$$) Reviews pull requests before humans do stamphog Request AI approval (no full review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants