Repository navigation
Description
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
- 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.
- 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-UTCTZset invi.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'stimezoneId: 'America/Toronto', create a batch and assert the created assignment'sexpiresAtis 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
joshunrau commentedon Oct 26, 2026
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/srcreadsexpiresAt(#1703). Onmainthe 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'sDateField, whose mount effect runssetValue(new Date(inputValue))on theYYYY-MM-DDstring (DateField.tsx:22-31). The prefilled instant becomes 00:00 UTC at the start of the date shown.remote-assignment.test.tsx:199-209pins this: the2026-01-08T12:00Zprefill is submitted asnew 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-68apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:136-137Both format
createdAtandexpiresAtwithtoBasicISOString. A local end of day set from Montréal isD+1T03:59:59.999Z, which both tables would show as D+1 to every viewer. The wizard's prefill atapps/web/src/routes/_app/group/remote-assignments.tsx:121-123is a UTC date too.Plan
apps/web/src/utils/local-date.tswithtoLocalDateString(date)(toLocalISOString(date).slice(0, 10);resultCsvFilenamealready uses libjs'stoLocalISOString) andendOfLocalDay(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.endOfLocalDayatuseBulkAssignmentWizard.ts:61. On the single-assignment page, transform first and check after:z.coerce.date().transform((value) => endOfLocalDay(toBasicISOString(value))), then a.refineagainstDate.now()as$FutureDatedoes. The check then reads the clock at submit and tests the instant that is sent.toBasicISOStringis right there only becauseDateFieldparses the field as UTC.toLocalDateStringatbulk-assignments.ts:219,TimepointsStep.tsx:54,remote-assignments.tsx:121, and in both columns of the two tables. Set the date input'smin(TimepointsStep.tsx:130) to tomorrow, sinceaddand the button already refuse today (lines 58 and 139).Tests
useBulkAssignmentWizard.test.ts:185-196, which pins2030-01-01T23:59:59.999Z, andremote-assignment.test.tsx:199-209. After the change,bulk-assignments.test.ts:259-269passes or fails depending on the machine's zone, so build its input from local components as theresultCsvFilenametest above it does.apps/web/src/utils/__tests__/local-date.test.ts, withTZ=America/Torontoset invi.hoisted.describeintesting/src/specs/remote-assignments-wizard.spec.tswithtest.use({ timezoneId: 'America/Toronto' })and a winter date so the offset is fixed. D =2030-01-15must be stored as2030-01-16T04:59:59.999Z, and the subject's Assignments tab must show2030-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 intesting/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 toremote-assignments-wizard.spec.ts.Related
Date, inparseWorkbook(bulk-assignments.ts:301), and can reusetoLocalDateString.TZset on the API container,formatExpiryDate(mail.utils.ts:29-39) formats in UTC. After this fix it would email that D as "January 16, 2030 at 4:59 AM UTC", where today it says "January 15, 2030 at 11:59 PM UTC". A follow-up can send the browser's IANA zone in$SendAssignmentEmailData.DateFieldcalendar appliestoBasicISOStringto a local-midnightDate(DateField.tsx:58), so east of UTC, picking a day writes the day before into the field.Triage note written with Claude Code. Code references point at
main@d86740e.