feat(surveys): show auto-close date for recurring surveys - #72955
Conversation
🤖 CI report
|
| 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%)
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 1 should fix, 2 consider. Published 3 findings (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
| 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 } |
There was a problem hiding this comment.
Auto-close schedule info ignores survey.end_date, so already-stopped recurring surveys show a stale/contradictory 'closes on X' message
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 newgetRecurringSurveyScheduleInfoinutils.ts, its three call sites (SurveyOverview.tsx,SurveyRepeatSchedule.tsx,SuccessStep.tsx), and howend_date/iteration fields are set on stop/archive insurveyLogic.tsx. - Found: The helper computes
autoCloseDate = dayjs(survey.start_date).add(count*frequency, 'day')with no reference tosurvey.end_date. Both display call sites guard only onautoCloseDate.isBefore(dayjs())— this catches 'computed date already passed' but NOT 'survey stopped early'.stopSurvey(surveyLogic.tsx:1714) setsend_date: dayjs().toISOString()andarchiveSurvey(surveyLogic.tsx:2047-2053) setsend_datewhile running; neither clearsiteration_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.tsxthis 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 vsend_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>
| /** | ||
| * 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 |
There was a problem hiding this comment.
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
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_datescome fromrrule(DAILY, count, interval=frequency, dtstart=start_date), solast_iteration_start = start_date + (count-1)*frequencyandfinal_iteration_end = last_iteration_start + frequency = start_date + count*frequency. The frontend'sautoCloseDate = dayjs(start_date).add(count*frequency, 'day')equalsfinal_iteration_endexactly. The task closes only whendate.today() > final_iteration_end(strict) and runs every 12h, so closure lands the day afterfinal_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>
| 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 } |
There was a problem hiding this comment.
getRecurringSurveyScheduleInfo computes the auto-close date in the browser's local timezone, but the backend computes it in UTC calendar days
Why we think it's a valid issue
- Checked:
lib/dayjs.ts(utc plugin extended at line 29; defaultdayjs(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 howstart_dategets 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 whenstart_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_dateis 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 owndayjs.utc()/TZLabelpatterns) 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>
🦔 Hogbox preview · ✅ ready▶ Open the preview
commit |
Prompt To Fix All With AIFix 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 |
There was a problem hiding this comment.
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.| 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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this 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.
| 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.|
Note 🤖 stamphog reviewed 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.
Gate mechanics and policy version
|
|
✅ Visual changes approved by @phillram — baseline updated in 2 changed. |
|
🔀 Tried to auto-resolve conflicts with I won't retry until the branch or master moves. |
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
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
c6a612a to
31f7e21
Compare
There was a problem hiding this comment.
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 |
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:
The close date is computed as
iteration_count * iteration_frequency_daysdays from the launch date, mirroring the backend logic inposthog/tasks/update_survey_iteration.py(the last iteration starts onstart_date + (count - 1) * frequencyand lastsfrequencymore 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.
How did you test this code?
Added a unit test for the new
getRecurringSurveyScheduleInfohelper inutils.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'scount * frequencyformula.Tested it locally and added screenshots to this PR.
Automatic notifications
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