Skip to content

Bulk remote assignments expire at midnight UTC on the chosen date, hours early for coordinators in the Americas #1711

Open

Description

@joshunrau

Bulk remote assignments expire at 23:59:59 UTC on the chosen date, not at the end of that day where the coordinator is. The wizard collects each timepoint's expiry as a bare yyyy-mm-dd from a date input, and toPayload turns it into new Date(${expiresAt}T23:59:59.999Z). In Montréal that is 19:59 (EDT) or 18:59 (EST) on the chosen day. A participant who opens the link that evening, still within the date shown in the wizard, the results CSV and the subject's assignment list, finds it already expired. East of UTC the link instead stays valid into the next morning. The single-assignment page (/session/remote-assignment) does not have this problem, because it submits a full date-time.

Where

apps/web/src/hooks/useBulkAssignmentWizard.ts:56-64:

const toPayload = (allowDuplicates: boolean) => ({
  allowDuplicates,
  groupId,
  subjectIds,
  timepoints: timepoints.map(({ expiresAt, instrumentId }) => ({
    expiresAt: new Date(`${expiresAt}T23:59:59.999Z`),
    instrumentId
  }))
});

The results export reads it back as a UTC date in apps/web/src/utils/bulk-assignments.ts:219 (toBasicISOString(new Date(assignment.expiresAt))), and the "expiry must be after today" check in apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx:55 also uses the UTC date (toBasicISOString(new Date())). So the three only agree with each other in UTC.

Reproduce

  1. In a browser set to America/Toronto, open Group Actions → Remote Assignments → Create Assignments, pick subjects, and add a timepoint expiring on a given date D.
  2. Create the batch and inspect the created assignment's expiresAt (API response or database).

Actual: expiresAt is D T23:59:59.999Z, i.e. 19:59 local on D. Opening the link at 20:00 local on D shows it as expired.
Expected: the assignment stays valid until the end of D in the coordinator's time zone (D T23:59:59.999 local, D+1 T03:59:59.999Z in EDT).

Tests

  • apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts: it('should expire each timepoint at the end of the chosen day in the local time zone, not in UTC'). Run it with a non-UTC TZ set in vi.hoisted.
  • apps/web/src/utils/__tests__/bulk-assignments.test.ts: it('should export a locally end-of-day expiry as the chosen calendar date').
  • testing/src/specs/remote-assignments-wizard.spec.ts: with the browser context's timezoneId: 'America/Toronto', create a batch and assert the created assignment's expiresAt is the end of the chosen local day.

Suggested fix

Build the instant from the local calendar date: new Date(${expiresAt}T23:59:59.999) (no Z). Format it back with local components wherever a bare date is shown or compared: buildResultRows, and the todayISO check in TimepointsStep.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Two corrections to the issue, and the fix has to reach more call sites than it lists.

    What the early instant breaks today

    The "link shows as expired at 20:00" part cannot happen yet: nothing in apps/gateway/src reads expiresAt (#1703). On main the early instant reaches only the email and the API's duplicate check, which stops counting the assignment as live from 19:59 EDT (assignments.service.ts:308). Once #1703 enforces expiry, participants are locked out at that time, so this should land before #1703 or with it. Rows created before the fix keep their stored instant. Nothing records the creator's zone, so they cannot be corrected.

    The single-assignment page is worse, not fine

    It does not submit a full date-time. kind: 'date' renders libui 6.16.1's DateField, whose mount effect runs setValue(new Date(inputValue)) on the YYYY-MM-DD string (DateField.tsx:22-31). The prefilled instant becomes 00:00 UTC at the start of the date shown. remote-assignment.test.tsx:199-209 pins this: the 2026-01-08T12:00Z prefill is submitted as new Date('2026-01-08'). In Montréal that assignment expires at 20:00 EDT / 19:00 EST on the evening before the date shown. The .min(new Date()) check (remote-assignment.tsx:182) runs on the same truncated value, so after that hour the page refuses tomorrow.

    The suggested fix moves two tables to D+1

    These show D today only because the stored instant is DT23:59:59.999Z:

    • apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx:67-68
    • apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:136-137

    Both format createdAt and expiresAt with toBasicISOString. A local end of day set from Montréal is D+1T03:59:59.999Z, which both tables would show as D+1 to every viewer. The wizard's prefill at apps/web/src/routes/_app/group/remote-assignments.tsx:121-123 is a UTC date too.

    Plan

    1. Add apps/web/src/utils/local-date.ts with toLocalDateString(date) (toLocalISOString(date).slice(0, 10); resultCsvFilename already uses libjs's toLocalISOString) and endOfLocalDay(isoDate) (new Date(`${isoDate}T23:59:59.999`)). The rule "a bare date is the coordinator's day" then lives in one file, the only one to change if Timezone (GMT, EST) to be selected by group manager so dates can be displayed accurately #1604 ever picks a group zone.
    2. Use endOfLocalDay at useBulkAssignmentWizard.ts:61. On the single-assignment page, transform first and check after: z.coerce.date().transform((value) => endOfLocalDay(toBasicISOString(value))), then a .refine against Date.now() as $FutureDate does. The check then reads the clock at submit and tests the instant that is sent. toBasicISOString is right there only because DateField parses the field as UTC.
    3. Use toLocalDateString at bulk-assignments.ts:219, TimepointsStep.tsx:54, remote-assignments.tsx:121, and in both columns of the two tables. Set the date input's min (TimepointsStep.tsx:130) to tomorrow, since add and the button already refuse today (lines 58 and 139).

    Tests

    • Update useBulkAssignmentWizard.test.ts:185-196, which pins 2030-01-01T23:59:59.999Z, and remote-assignment.test.tsx:199-209. After the change, bulk-assignments.test.ts:259-269 passes or fails depending on the machine's zone, so build its input from local components as the resultCsvFilename test above it does.
    • apps/web/src/utils/__tests__/local-date.test.ts, with TZ=America/Toronto set in vi.hoisted.
    • e2e: a new describe in testing/src/specs/remote-assignments-wizard.spec.ts with test.use({ timezoneId: 'America/Toronto' }) and a winter date so the offset is fixed. D = 2030-01-15 must be stored as 2030-01-16T04:59:59.999Z, and the subject's Assignments tab must show 2030-01-15, which catches the table regression. The file's header says it creates no assignments, so update it. Do the same for the single-assignment page in testing/src/specs/remote-assignment.spec.ts. fix(web): align remote assignment table colors with datahub subject table #1811 and fix: resolve 26 low-difficulty issues, one commit each #1825 both add tests to remote-assignments-wizard.spec.ts.

    Related

    Triage note written with Claude Code. Code references point at main @ d86740e.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions