You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-08-25] [$250] German user cannot save a Per Diem (gets the "Please choose a time that is at least one minute in the future." error) #97796
Version Number: Reproducible in staging?: Needs Reproduction Reproducible in production?: Needs Reproduction If this was caught during regression testing, add the test name, ID and link from BrowserStack: Email or phone of affected tester (no customers): Logs:https://stackoverflow.com/c/expensify/questions/4856 Expensify/Expensify Issue URL: Issue reported by:@jliexpensify Slack conversation (hyperlinked to channel name): #Expensift Bugs
Action Performed:
Set phone timezone/location and New Expensify language to German
Upload Per Diem rates
Create Per Diem - the default is at 12:00 AM (start) and 12:00 PM (end) times
Try switching start time to 08:00 AM
Try switching end time to 02:00 PM
Try saving and get the error
Expected Result:
You should be able to save the end time as 02:00 PM.
Actual Result:
Unable to save and error message displayed as Please choose a time that is at least one minute in the future.
Workaround:
Unknown
Platforms:
Select the officially supported platforms where the issue was reproduced:
TL;DR: In any non-English locale (German here), the app sets a global date-fns locale, but the time picker always emits the hardcoded English tokens AM/PM. The internal hh:mm a parser then runs under the German locale (which expects vorm./nachm.), fails, and returns an empty string — so the Per Diem start/end times become empty and the range check falsely fails with invalidTimeShouldBeFuture.
The Per Diem time picker always produces hardcoded EnglishAM/PM strings — CONST.TIME_PERIOD = {AM: 'AM', PM: 'PM'} — so the picker outputs e.g. "08:00 AM" / "02:00 PM" regardless of language.
DateUtils.combineDateAndTime parses that string with parse(updatedTime, 'hh:mm a', new Date())and passes no explicit locale, so the a (meridiem) token is matched against the global German locale, which only accepts vorm./nachm. — not AM/PM. Parsing fails → the function returns ''.
In IOURequestStepTime.validate, both newStart and newEnd come back as '', so isValidStartEndTimeRange does isAfter(new Date(''), new Date('')) → false, and the misleading common.error.invalidTimeShouldBeFuture error is shown on the end-time field.
This affects every locale whose date-fns meridiem tokens aren't AM/PM (i.e. most non-English locales), not just German. The invalidTimeShouldBeFuture message is also semantically wrong for this screen — the actual check is a start-before-end range check, not a future check.
What changes do you think we should make in order to solve the problem?
Make the internal hh:mm a parsing locale-independent. The strings being parsed (combineDateAndTime and get12HourTimeObjectFromDate) are internal, machine-generated values that always use the fixed English AM/PM tokens from CONST.TIME_PERIOD — they should not be interpreted through the user's display locale. Pass an explicit English locale to those parse calls (e.g. parse(updatedTime, 'hh:mm a', new Date(), {locale: enUS})) in combineDateAndTime and get12HourTimeObjectFromDate. This keeps parsing consistent with the fixed tokens the picker produces, independent of the global setDefaultOptions locale.
Investigation details, alternatives & repro
Repro: set app language to German, create a Per Diem, pick start 08:00 AM / end 02:00 PM, save → invalidTimeShouldBeFuture error. Fully deterministic from the code path above; no production logs needed to establish the mechanism.
Why the "future" message even appears: the Per Diem picker sets shouldValidateFutureTime={false} in TimeModalPicker, so the real future-check is skipped; the empty-string range check just reuses the same error key.
Alternative fix: localize CONST.TIME_PERIOD and parse against the current locale everywhere. This is more invasive and risks other regressions across all hh:mm a call sites — the internal strings are conceptually locale-neutral, so pinning the parser to English is the smaller, safer change.
Related fallout: the same global-locale-vs-hh:mm a mismatch also affects display/seed paths (e.g. get12HourTimeObjectFromDate returns period: format(parsedTime, 'a').toUpperCase() → VORM./NACHM. in German), so fixing both call sites addresses the picker's initial period value too.
What alternative solutions did you explore? (Optional)
Localizing the AM/PM tokens app-wide (see details above) — rejected as more invasive and higher-risk than pinning the internal parser to a fixed English locale.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/97796) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
The Per Diem time picker mixes two incompatible conventions for the AM/PM token: the seed/display side follows the user's global date-fns locale, while the toggle button writes a hardcoded English token — and the validation parser reads it back under the global locale again. For a German user those don't line up, so parsing silently fails and the range check reports a bogus error.
Concretely:
When German loads, IntlStore installs a process-wide date-fns locale via setDefaultOptions({locale: dateUtilsLocale}). From now on every locale-less parse/format uses de, whose meridiem tokens are vorm./nachm., not AM/PM.
The picker seeds its value with extractTime12Hour, which does format(date, 'hh:mm a') with no locale — under de this produces "12:00 nachm.", i.e. a localized string. So the untouched default actually parses fine.
The AM/PM toggle is different: tapping it writes the fixed English constant CONST.TIME_PERIOD.AM/PM into amPmValue (TimePicker.tsx L748-L763), and the form value is rebuilt as `${hours}:${minutes} ${amPmValue}` (L726-L729). So the moment the user interacts with the toggle (step 4/5 of the repro), the form value flips to English "08:00 AM" / "02:00 PM".
On save, IOURequestStepTime.validate runs each value through combineDateAndTime, whose parse(updatedTime, 'hh:mm a', new Date()) again has no locale. Under de the a token can't match "AM"/"PM", so isValid is false and the function returns ''.
Both newStart and newEnd come back '', so isValidStartEndTimeRange evaluates isAfter(new Date(''), new Date('')) → false, and the misleading common.error.invalidTimeShouldBeFuture is attached to the end-time field.
So the trigger is specifically interacting with the AM/PM toggle, not the picker in general — which matches the repro exactly. It reproduces in any locale whose date-fns meridiem isn't AM/PM.
One trap worth flagging for the fix: get12HourTimeObjectFromDate parses 'hh:mm a' the same way but, unlike combineDateAndTime, has no isValid guard before format(parsedTime, 'hh'). If its input ever fails to parse, format throws RangeError: Invalid time value. Today it's safe only because the German seed ("12:00 nachm.") and the parse both use the global de locale. That coupling matters below.
Verified: IntlStore.ts#L202-205, DateUtils.ts#L451-457 (extractTime12Hour), #L563-570 (combineDateAndTime), #L607-625 (get12HourTimeObjectFromDate), #L652-655 (isValidStartEndTimeRange), TimePicker.tsx#L726-729, #L748-763, IOURequestStepTime.tsx#L113-124, CONST/index.ts#L6218-6221 — all read in code. Unverified: none load-bearing (date-fns de meridiem tokens vorm./nachm. are standard locale data).
What changes do you think we should make in order to solve the problem?
Treat the 12-hour time string as an internal, locale-neutral value and pin the whole pipeline to a fixed English locale — not just the parsers. CONST.TIME_PERIOD already hardcodes English AM/PM, so English is the format this picker is designed around; the visible toggle labels stay localized because they render translate('common.am'/'common.pm'), independent of the stored token.
Pass {locale: enUS} (from date-fns/locale) to all four helpers so producer and consumer agree:
format in extractTime12Hour and formatDateTimeTo12Hour (the seed/write side).
Fixing only the parsers (as the auto-generated proposal above suggests) is not safe: extractTime12Hour would still seed the German "12:00 nachm.", which an enUS-pinned get12HourTimeObjectFromDate can't parse, so its unguarded format at L617-L618 throws and the picker crashes on mount for German users. Pinning the format producers too keeps the seed English ("12:00 PM") and closes that hole.
Fix layer is the DateUtils parsing/formatting helpers (data), not render — the AM/PM token is a machine value flowing picker → form → validation and shouldn't pass through the display locale at all. No API/Onyx shape changes, so offline and optimistic paths are untouched; the existing empty-input early returns in combineDateAndTime are unchanged.
Separately (smaller, optional follow-up): the error key common.error.invalidTimeShouldBeFuture at IOURequestStepTime.tsx#L121 is semantically wrong here — isValidStartEndTimeRange is a start-before-end check, not a future check — so even the message a user hits is misleading. Worth swapping for an "end must be after start" key.
What alternative solutions did you explore? (Optional)
Localizing CONST.TIME_PERIOD so the toggle emits the locale's meridiem and leaving parse/format on the global locale. It's internally coherent but more invasive, and it has a concrete failure: StyleUtils.getStatusAMandPMButtonStyle(amPmValue) (TimePicker.tsx L711) decides which button is highlighted by comparing amPmValue against the English CONST.TIME_PERIOD values — swap those for localized strings and the selected-state highlight silently breaks. Pinning the internal representation to English is the smaller, lower-risk change.
I also considered dropping the process-wide setDefaultOptions in IntlStore.ts#L205 and passing locale explicitly at display sites. That's the actual global source of the mismatch, but every date/relative-time render in the app depends on that default — removing it to fix one picker would regress formatting app-wide. Rejected as far out of scope.
🚨 Edited by proposal-police: This proposal was edited at 2026-08-07 04:58:00 UTC.
Proposal
Updated: added a compare branch with the implementation, regression tests across all 11 locales, and an empirical comparison of the candidate fixes. The root cause below is unchanged from my original proposal; the solution section is narrowed after testing showed the broader variant regresses localized display.
What is the root cause of that problem?
The time picker emits the hardcoded English meridiem tokens AM/PM, but every internal hh:mm a parse in DateUtils runs against the process-widedate-fns locale. For a German user those two disagree, the parse returns Invalid Date, and the time silently collapses to '' — which then trips the range check and surfaces the wrong "future time" error.
Walking the chain:
When the language loads, IntlStore sets a global date-fns locale via setDefaultOptions({locale: dateUtilsLocale}) — de for German. From then on, every bare parse()/format() in DateUtils uses German, whose meridiem matcher only accepts vorm./nachm., not AM/PM.
The AM/PM buttons set amPmValue to the fixed English CONST.TIME_PERIOD.AM/PM (button handlers; there is also an auto-switch to PM when an hour > 12 is typed, at L304-L309). So the moment the user taps AM or PM, handleSubmit produces "08:00 AM" / "02:00 PM" — English tokens, under a German locale.
On save, IOURequestStepTime.validate re-combines the (now empty) times, and isValidStartEndTimeRange evaluates isAfter(new Date(''), new Date('')) → false, reusing the common.error.invalidTimeShouldBeFuture key. The copy is misleading: the future check is explicitly disabled here via shouldValidateFutureTime={false}; the check that actually fails is the empty-string start-before-end comparison.
Exactly which locales are affected. I ran format/parse under all 11 locales IntlStore can install:
locales
parse('02:00 PM', 'hh:mm a') → Invalid Date (save is broken)
de, el, zh-hans
format(d, 'hh:mm a') → not AM/PM (period token is localized)
de, el, zh-hans, ja
unaffected
en, es, fr, it, nl, pl, pt-BR
Japanese is the interesting case: its format yields 午後, but its matcher also accepts am/pm, so saving works while the period state is still wrong.
Second, visible symptom from the same cause. Before the user touches anything, amPmValue is seeded from get12HourTimeObjectFromDate(...).period = format(parsedTime, 'a').toUpperCase() → "NACHM.". getStatusAMandPMButtonStyle compares that against CONST.TIME_PERIOD, so in de/el/ja/zh-hans neither AM nor PM renders as selected — which is precisely what pushes the user to tap a button and trigger the save failure.
Also affected: Profile → Status → Clear after → custom time (SetTimePage.tsx#L26) shares these helpers, and there shouldValidateFutureTime defaults to true, so the same message appears inside the picker. Per Diem is hit on both the create and the edit step.
What changes do you think we should make in order to solve the problem?
My original proposal said the internal 12-hour representation should be pinned to a fixed English locale, and named the four hh:mm a call sites. Implementing it showed the thesis is right but one of those call sites is doing two jobs: extractTime12Hour produces both the value TimePicker holds and the row the user reads. Pinning it fixes the save but turns μ.μ. → PM for Greek, 午後 → PM for Japanese and 下午 → PM for Chinese.
So the fix is to split those two roles, and pin only the machine-readable one.
1. DateUtils — one formatter per role.
functionformatTime12Hour(dateTimeString: string,isFullFormat: boolean,locale?: DateFnsLocale): string{if(!dateTimeString||dateTimeString==='never'){return'';}constdate=newDate(dateTimeString);returnformat(date,isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a',{locale});}/** Formats a time for display, so its day period follows the language the user picked. */functionextractTime12Hour(dateTimeString: string,isFullFormat=false): string{returnformatTime12Hour(dateTimeString,isFullFormat);}/** Formats the time that TimePicker keeps as its value: machine readable, so pinned to English. */functionextractInternalTime12Hour(dateTimeString: string,isFullFormat=false): string{returnformatTime12Hour(dateTimeString,isFullFormat,enGB);}
extractTime12Hour behaves exactly as it does today, so every user-facing row and every translation is untouched.
2. TimePicker seeds from the internal formatter.
- const value = DateUtils.extractTime12Hour(defaultValue, showFullFormat);+ // This is the picker's own value, not display text, so it keeps the fixed AM/PM tokens in every language.+ const value = DateUtils.extractInternalTime12Hour(defaultValue, showFullFormat);
3. get12HourTimeObjectFromDate reads it back with the same locale, and guards the result.
- const parsedTime = parse(dateTime, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', new Date());+ const parsedTime = parse(dateTime, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', new Date(), {locale: enGB});+ if (!isValid(parsedTime)) {+ return defaultTimeObject;+ }
...
- period: format(parsedTime, 'a').toUpperCase(),+ period: format(parsedTime, 'a', {locale: enGB}).toUpperCase(),
Without the guard a failed parse reaches format(parsedTime, 'hh') and throws RangeError: Invalid time value, taking the picker down on mount. Pinning the period is what restores the AM/PM button highlight.
4. combineDateAndTime — parse the picker's value with the same locale.
- // it's in "hh:mm a" format- const tempTime = parse(updatedTime, 'hh:mm a', new Date());+ // It's in "hh:mm a" format. The day period is always one of the fixed CONST.TIME_PERIOD tokens, so it has to be+ // parsed with a fixed English locale instead of the global one IntlStore installs.+ const tempTime = parse(updatedTime, 'hh:mm a', new Date(), {locale: enGB});
enGB rather than enUS because that is the exact date-fns locale IntlStore loads for English (IntlStore.ts#L77-L87), so English behaviour is unchanged. I checked every caller of combineDateAndTime — TimeModalPicker, SetTimePage, IOURequestStepTime (×4), SetDatePage and isTimeAtLeastOneMinuteInFuture — and none reaches the hh:mm a branch with a localized string; SetDatePage and the IOURequestStepTime form values take the yyyy-MM-dd HH:mm:ss branch, which has no locale-sensitive token.
Net effect: the picker → form → validation pipeline is English end to end, with no format → parse round trip through the display locale left anywhere in it. Every display path keeps following the language.
src change is +57 / −18 across two files, most of it comments and the two thin wrappers. No form, API, Onyx or translation changes.
I added coverage to tests/unit/DateUtilsTest.ts that loads each language through IntlStore and replays the picker flow, parameterised over all 11 locales. The regression block deliberately uses only helpers that already exist, so it can be run against main unchanged:
assertion
on main
after
combineDateAndTime parses the picker output
❌ de, el, zh-hans
✅
get12HourTimeObjectFromDate returns a CONST.TIME_PERIOD token (button highlight)
❌ de, el, ja, zh-hans
✅
a Per Diem saves after the user switches the day period
❌ de, el, zh-hans
✅
a Per Diem saves when the day period is left untouched
✅
✅
the displayed time stays localized
✅
✅
noon/midnight land on the right period
❌
✅
an unparseable time falls back instead of throwing
The last two rows of the table are the ones worth pointing at: they pass on mainand after the fix. Those are the two cases a format-side pin breaks — the untouched-period path, and localized display — and they are why this change is narrower than my original one.
Full DateUtilsTest: 149/149 green. CalendarPickerTest, TimeTrackingUtilsTest, getTimeDifferenceIntervalsTest: 51/51. ESLint: 0 errors. tsc: error set identical to upstream/main, so this adds none.
Follow-ups I found while implementing, but deliberately left out of scope
common.error.invalidTimeShouldBeFuture is the wrong copy on this screen — the check is start-before-end, not a future check. Fixing it needs a new key in 11 languages, so it belongs in its own issue.
TimePicker.handleSubmit drops the day period entirely on the showFullFormat path (L731-L738 emits "11:10:10.123" with no a token), so combineDateAndTime returns '' and validation always fails. Locale-independent — it fails in English too — and today only the Debug date-time page passes showFullFormat, so it is a separate, non-user-facing bug.
Per Diem has a third 12-hour display path with its own formatter, PerDiemRequestUtils.formatDateTimeTo12Hour, alongside DateUtils.formatDateTimeTo12Hour on the status page. This change leaves all of them on the global locale, so they stay consistent with each other. Worth noting for any proposal that re-localizes the picker row through translate('common.am'/'common.pm'): that only covers two of the four, and would leave a Greek user reading μμ. on the picker row and μ.μ. on the Per Diem confirmation row of the same flow.
What alternative solutions did you explore? (Optional)
Pin the format side too (extractTime12Hour, formatDateTimeTo12Hour) so the whole 12-hour string is English. This is my original proposal. It works functionally, but as shown above it turns four user-facing display paths English in el/ja/zh-hans, or forces a translate-based re-localization that only covers two of them. Rejected in favour of the narrower change.
Localize CONST.TIME_PERIOD so the toggle emits the locale's meridiem. Rejected: getStatusAMandPMButtonStyle compares amPmValue against the English constants, so localizing the stored token silently breaks the selected-state highlight, and date-fns' tokens (vorm., π.μ., 上午) are not the app's own translations, which would mean keeping a second token table in sync.
Fix it only in IOURequestStepTime.validate. Rejected: the value is already '' by the time validate runs — it was destroyed in TimeModalPicker.updateInput. Patching validate would leave the picker writing empty times everywhere else combineDateAndTime is used.
Keep the localized round trip and only normalize the period numerically (parsedTime.getHours() >= 12 ? PM : AM), leaving extractTime12Hour to seed the picker. I built this first and it does pass every test above, with a smaller diff. I moved away from it because it keeps the picker's seed flowing through format under one locale and parse under the same locale, so it stays correct only as long as every date-fns locale's formatter and matcher are symmetric. I verified they are for all 11 locales today, but that is an assumption about vendored locale data, not something our code enforces — and it is the exact assumption that broke here. Splitting the roles removes the round trip instead of relying on it.
Move the picker contract to 24-hour HH:mm. The most locale-proof shape, and it removes the meridiem token from the wire format entirely. Rejected for this bug: it touches every TimePicker consumer plus the showFullFormat path, for the same user-facing result. A good follow-up cleanup, but more churn than this bug warrants.
Drop the global setDefaultOptions in IntlStore. That is the true source of the ambient dependency, but every date and relative-time render in the app relies on it. Far out of scope.
Please re-state the problem that we are trying to solve in this issue.
A German user cannot save a Per Diem expense. After picking a start/end time, the form fails validation with the misleading error "Please choose a time at least one minute ahead".
What is the root cause of that problem?
The time picker's internal 12-hour strings are parsed and formatted through the user's display locale, but the AM/PM tokens they contain are hardcoded English. In locales whose date-fns meridiem tokens aren't AM/PM, that parse fails and the time silently becomes an empty string.
Step by step:
When a language loads, IntlStore sets a process-wide date-fns locale via setDefaultOptions({locale: dateUtilsLocale}). Every unqualified format/parse call in the app now runs under that locale.
The time picker emits the fixed English tokens from CONST.TIME_PERIOD whenever the user taps the AM/PM buttons (TimePicker.tsx#L750, #L762) or types an hour > 12 (#L308) — so onSubmit receives e.g. "02:00 PM" regardless of language.
DateUtils.combineDateAndTime parses that with parse(updatedTime, 'hh:mm a', new Date()) and passes no locale. Under de, the a token only matches vorm./nachm., so the parse returns Invalid Date and the function returns ''.
That '' is what TimeModalPicker.updateInput writes into the form field, so the Per Diem start/end times are stored empty.
On submit, IOURequestStepTime.validate ends up calling isAfter(new Date(''), new Date('')) → false, and shows common.error.invalidTimeShouldBeFuture.
Verified against the shipped locale data.de's matchDayPeriodPatterns is /^(vorm\.?|nachm\.?|Mitternacht|Mittag|morgens|abends|nachts)/i — AM/PM simply don't match. Running parse('02:00 PM', 'hh:mm a', new Date()) under each of the app's 11 locales:
parse('02:00 PM') fails
format(date, 'a') is not AM/PM
fully unaffected
de, el, zh-Hans
de, el, zh-Hans, ja
en, es, fr, it, nl, pl, pt-BR
So this is not German-specific — Greek and Chinese are broken the same way, and Japanese is affected by the format half.
Why it looks intermittent, and why the user has to touch the AM/PM buttons.amPmValue is seeded from get12HourTimeObjectFromDate(...).period, which is format(parsedTime, 'a').toUpperCase() (DateUtils.ts#L623) → "VORM." in German. That value round-trips fine, so not touching the period actually works. But getStatusAMandPMButtonStyle compares amPmValue against CONST.TIME_PERIOD, so with "VORM."neither button is highlighted — the user naturally taps one to confirm the period, which swaps in the English token and breaks the parse. That's a second, visible bug from the same root cause.
Additional beyond Per Diem. Custom Status → Clear after → SetTimePage.tsx#L26 uses the same combineDateAndTime path and is broken identically in de/el/zh-Hans.
What changes do you think we should make in order to solve the problem?
These 12-hour strings are an internal, machine-readable representation produced and consumed by our own code with fixed CONST.TIME_PERIOD tokens. They should never be interpreted through the user's display locale. So: pin the whole internal round-trip to a fixed locale, and translate the meridiem only at the point of display.
1. src/libs/DateUtils.ts — pin the internal round-trip
import{enUS}from'date-fns/locale/en-US';/** * The 12-hour time strings that the time picker produces and consumes are an internal, machine-readable representation: * they always use the fixed `CONST.TIME_PERIOD` (AM/PM) tokens. `IntlStore` sets a process-wide date-fns locale, so * without pinning the locale the `a` (meridiem) token would be formatted/parsed with the user's language (e.g. `vorm.` * in German) and parsing `AM`/`PM` would fail. Always pass this locale when formatting or parsing those strings, and * use `getLocalizedTime12Hour` when the time needs to be shown to the user instead. */constINTERNAL_TIME_LOCALE=enUS;
extractTime12Hour (the producer): format(date, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', {locale: INTERNAL_TIME_LOCALE})
combineDateAndTime (the consumer, #L565): parse(updatedTime, 'hh:mm a', new Date(), {locale: INTERNAL_TIME_LOCALE})
get12HourTimeObjectFromDate — pin the parse, guard the result, and derive the period from the hour instead of formatting it:
functionget12HourTimeObjectFromDate(dateTime: string,isFullFormat=false): {hour: string;minute: string;seconds: string;milliseconds: string;period: string}{constdefaultTimeObject={hour: '12',minute: '00',seconds: '00',milliseconds: '000',period: CONST.TIME_PERIOD.PM};if(!dateTime){returndefaultTimeObject;}constparsedTime=parse(dateTime,isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a',newDate(),{locale: INTERNAL_TIME_LOCALE});if(!isValid(parsedTime)){returndefaultTimeObject;}return{hour: format(parsedTime,'hh'),minute: format(parsedTime,'mm'),seconds: isFullFormat ? format(parsedTime,'ss') : '00',milliseconds: isFullFormat ? format(parsedTime,'SSS') : '000',// The period is derived from the hours instead of being formatted, so that it always matches the fixed CONST.TIME_PERIOD tokens the picker works with.period: parsedTime.getHours()>=12 ? CONST.TIME_PERIOD.PM : CONST.TIME_PERIOD.AM,};}
Deriving the period numerically also fixes the unhighlighted AM/PM buttons, since amPmValue is now always AM/PM.
⚠️The isValid guard is required, not defensive polish. Without it, a failed parse reaches format(parsedTime, 'hh'), which throws RangeError: Invalid time value. Any partial version of this fix that pins get12HourTimeObjectFromDate but leaves extractTime12Hour localized will crash the time picker on mount in German, because extractTime12Hour would still hand it "12:00 vorm.". Producer and consumer have to move together.
2. Keep display localized — add a display-only helper
/** * Returns a 12-hour time for display, with the meridiem translated so that it matches the AM/PM labels of the time picker. * returns {string} example: 11:10 PM, or 11:10 μμ. in Greek */functiongetLocalizedTime12Hour(translate: LocalizedTranslate,dateTimeString: string,isFullFormat=false): string{consttime=extractTime12Hour(dateTimeString,isFullFormat);if(!time){return'';}returntime.replace(CONST.TIME_PERIOD.AM,translate('common.am')).replace(CONST.TIME_PERIOD.PM,translate('common.pm'));}
Without this step, pinning extractTime12Hour would regress display for Greek (π.μ./μμ.), Japanese and Chinese users, who'd start seeing English AM/PM. Routing display through common.am/common.pm instead of date-fns has the added benefit that the displayed time now always matches the picker's own button labels, which is not true today.
Out of scope but worth a follow-up:common.error.invalidTimeShouldBeFuture is the wrong copy for this screen regardless of this bug — TimeModalPicker sets shouldValidateFutureTime={false}, so the real check is start-before-end, not a future check. Fixing it means a new translation key across 11 languages, which belongs in its own issue.
What alternative solutions did you explore? (Optional)
Localize CONST.TIME_PERIOD and parse everything against the current locale. Rejected — it makes a machine-readable value locale-dependent, spreads locale awareness across every hh:mm a call site, and any locale where date-fns' format/parse day-period tokens aren't perfectly symmetric would break silently. Pinning the internal representation is smaller and strictly more robust.
Pin only combineDateAndTime. Rejected — it fixes saving but leaves get12HourTimeObjectFromDate returning "VORM.", so the AM/PM buttons stay unhighlighted and the underlying inconsistency remains one refactor away from returning.
Normalize the period numerically but leave everything else on the global locale (i.e. rely on format/parse being symmetric per locale). I verified this does round-trip correctly in all 11 locales today, and it's a smaller diff — but it keeps the internal path dependent on mutable global state, which is exactly the defect class that caused this bug. Not worth the saved lines.
That PR started calling setDefaultOptions({locale: dateUtilsLocale}) for every supported language — including German — as part of the lazy date-fns loader. From that merge on, selecting German makes the process‑wide date‑fns meridiem vorm./nachm., which the picker's hardcoded English AM/PM tokens can no longer parse → combineDateAndTime returns '' → the misleading invalidTimeShouldBeFuture error on the Per Diem time step.
Heads up: this is a latent interaction bug, not a clean single-PR break — so pick where you comment based on what BugZero is after:
Root-cause enabler (recommended): #64619. It's the change that made the global date-fns default localized per language.
Feature that carries the fragile code: #54252 — "Implemented per diem request flow" (2025-01-02, shubham1206agra). It introduced the locale-less parse(updatedTime, 'hh:mm a', new Date()) in combineDateAndTime and the start/end validation — but it was harmless on its own.
Before feat: handle lazy load date fns locales #64619:DateUtils.setLocale only wired enGB/es and had a default: break, so every other language fell back to date-fns' built-in enUS, where AM/PM parse fine. So German never triggered the bug.
feat: handle lazy load date fns locales #64619 (commit d4cee82ba926, src/languages/TranslationStore.ts): added const dateUtilsLocale = this.dateUtilsCache.get(locale); if (dateUtilsLocale) setDefaultOptions({locale: dateUtilsLocale}) and wired real date-fns locales for de, fr, it, ja, nl, pl, … German's meridiem (vorm./nachm.) became the process-wide default the moment a user selects German. Verified in the d4cee82ba926 blob: setDefaultOptions import, dateUtilsCache, and import('date-fns/locale/de').
Implemented per diem request flow #54252 (commit 83128dd7, src/libs/DateUtils.ts):combineDateAndTime parses the picker's fixed English AM/PM with a locale-less parse(…, 'hh:mm a'), and IOURequestStepTime runs the start/end range check.
Activity
MelvinBot commentedon Aug 4, 2026
Proposal
TL;DR: In any non-English locale (German here), the app sets a global
date-fnslocale, but the time picker always emits the hardcoded English tokensAM/PM. The internalhh:mm aparser then runs under the German locale (which expectsvorm./nachm.), fails, and returns an empty string — so the Per Diem start/end times become empty and the range check falsely fails withinvalidTimeShouldBeFuture.What is the root cause of that problem?
IntlStoresets a process-widedate-fnsdefault locale viasetDefaultOptions({locale: dateUtilsLocale})(e.g.defor German).AM/PMstrings —CONST.TIME_PERIOD = {AM: 'AM', PM: 'PM'}— so the picker outputs e.g."08:00 AM"/"02:00 PM"regardless of language.DateUtils.combineDateAndTimeparses that string withparse(updatedTime, 'hh:mm a', new Date())and passes no explicit locale, so thea(meridiem) token is matched against the global German locale, which only acceptsvorm./nachm.— notAM/PM. Parsing fails → the function returns''.IOURequestStepTime.validate, bothnewStartandnewEndcome back as'', soisValidStartEndTimeRangedoesisAfter(new Date(''), new Date(''))→false, and the misleadingcommon.error.invalidTimeShouldBeFutureerror is shown on the end-time field.This affects every locale whose
date-fnsmeridiem tokens aren'tAM/PM(i.e. most non-English locales), not just German. TheinvalidTimeShouldBeFuturemessage is also semantically wrong for this screen — the actual check is a start-before-end range check, not a future check.What changes do you think we should make in order to solve the problem?
Make the internal
hh:mm aparsing locale-independent. The strings being parsed (combineDateAndTimeandget12HourTimeObjectFromDate) are internal, machine-generated values that always use the fixed EnglishAM/PMtokens fromCONST.TIME_PERIOD— they should not be interpreted through the user's display locale. Pass an explicit English locale to thoseparsecalls (e.g.parse(updatedTime, 'hh:mm a', new Date(), {locale: enUS})) incombineDateAndTimeandget12HourTimeObjectFromDate. This keeps parsing consistent with the fixed tokens the picker produces, independent of the globalsetDefaultOptionslocale.Investigation details, alternatives & repro
08:00 AM/ end02:00 PM, save →invalidTimeShouldBeFutureerror. Fully deterministic from the code path above; no production logs needed to establish the mechanism.shouldValidateFutureTime={false}inTimeModalPicker, so the real future-check is skipped; the empty-string range check just reuses the same error key.CONST.TIME_PERIODand parse against the current locale everywhere. This is more invasive and risks other regressions across allhh:mm acall sites — the internal strings are conceptually locale-neutral, so pinning the parser to English is the smaller, safer change.hh:mm amismatch also affects display/seed paths (e.g.get12HourTimeObjectFromDatereturnsperiod: format(parsedTime, 'a').toUpperCase()→VORM./NACHM.in German), so fixing both call sites addresses the picker's initial period value too.What alternative solutions did you explore? (Optional)
Localizing the AM/PM tokens app-wide (see details above) — rejected as more invasive and higher-risk than pinning the internal parser to a fixed English locale.
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/97796)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
melvin-bot commentedon Aug 4, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @aimane-chnaif (
External)melvin-bot commentedon Aug 4, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022084640862294996903
trjExpensify commentedon Aug 5, 2026
@aimane-chnaif what do you think of Melvin's proposal? Per Diem is super popular in Germany, so we should get this fixed ASAP.
aimane-chnaif commentedon Aug 6, 2026
@MelvinBot draft PR. Checking if your solution works.
aimane-chnaif commentedon Aug 7, 2026
Melvin isn't responsive.
🆘 C+ Help Wanted
Abdulloh0109 commentedon Aug 7, 2026
Proposal
What is the root cause of that problem?
The Per Diem time picker mixes two incompatible conventions for the AM/PM token: the seed/display side follows the user's global
date-fnslocale, while the toggle button writes a hardcoded English token — and the validation parser reads it back under the global locale again. For a German user those don't line up, so parsing silently fails and the range check reports a bogus error.Concretely:
IntlStoreinstalls a process-widedate-fnslocale viasetDefaultOptions({locale: dateUtilsLocale}). From now on every locale-lessparse/formatusesde, whose meridiem tokens arevorm./nachm., notAM/PM.extractTime12Hour, which doesformat(date, 'hh:mm a')with no locale — underdethis produces"12:00 nachm.", i.e. a localized string. So the untouched default actually parses fine.CONST.TIME_PERIOD.AM/PMintoamPmValue(TimePicker.tsx L748-L763), and the form value is rebuilt as`${hours}:${minutes} ${amPmValue}`(L726-L729). So the moment the user interacts with the toggle (step 4/5 of the repro), the form value flips to English"08:00 AM"/"02:00 PM".IOURequestStepTime.validateruns each value throughcombineDateAndTime, whoseparse(updatedTime, 'hh:mm a', new Date())again has no locale. Underdetheatoken can't match"AM"/"PM", soisValidis false and the function returns''.newStartandnewEndcome back'', soisValidStartEndTimeRangeevaluatesisAfter(new Date(''), new Date(''))→false, and the misleadingcommon.error.invalidTimeShouldBeFutureis attached to the end-time field.So the trigger is specifically interacting with the AM/PM toggle, not the picker in general — which matches the repro exactly. It reproduces in any locale whose
date-fnsmeridiem isn'tAM/PM.One trap worth flagging for the fix:
get12HourTimeObjectFromDateparses'hh:mm a'the same way but, unlikecombineDateAndTime, has noisValidguard beforeformat(parsedTime, 'hh'). If its input ever fails to parse,formatthrowsRangeError: Invalid time value. Today it's safe only because the German seed ("12:00 nachm.") and the parse both use the globaldelocale. That coupling matters below.Verified:
IntlStore.ts#L202-205,DateUtils.ts#L451-457(extractTime12Hour),#L563-570(combineDateAndTime),#L607-625(get12HourTimeObjectFromDate),#L652-655(isValidStartEndTimeRange),TimePicker.tsx#L726-729,#L748-763,IOURequestStepTime.tsx#L113-124,CONST/index.ts#L6218-6221— all read in code. Unverified: none load-bearing (date-fnsdemeridiem tokensvorm./nachm.are standard locale data).What changes do you think we should make in order to solve the problem?
Treat the 12-hour time string as an internal, locale-neutral value and pin the whole pipeline to a fixed English locale — not just the parsers.
CONST.TIME_PERIODalready hardcodes EnglishAM/PM, so English is the format this picker is designed around; the visible toggle labels stay localized because they rendertranslate('common.am'/'common.pm'), independent of the stored token.Pass
{locale: enUS}(fromdate-fns/locale) to all four helpers so producer and consumer agree:parseincombineDateAndTimeandget12HourTimeObjectFromDate(the read side — fixes the actual error).formatinextractTime12HourandformatDateTimeTo12Hour(the seed/write side).Fixing only the parsers (as the auto-generated proposal above suggests) is not safe:
extractTime12Hourwould still seed the German"12:00 nachm.", which anenUS-pinnedget12HourTimeObjectFromDatecan't parse, so its unguardedformatat L617-L618 throws and the picker crashes on mount for German users. Pinning the format producers too keeps the seed English ("12:00 PM") and closes that hole.Fix layer is the DateUtils parsing/formatting helpers (data), not render — the AM/PM token is a machine value flowing picker → form → validation and shouldn't pass through the display locale at all. No API/Onyx shape changes, so offline and optimistic paths are untouched; the existing empty-input early returns in
combineDateAndTimeare unchanged.Separately (smaller, optional follow-up): the error key
common.error.invalidTimeShouldBeFutureat IOURequestStepTime.tsx#L121 is semantically wrong here —isValidStartEndTimeRangeis a start-before-end check, not a future check — so even the message a user hits is misleading. Worth swapping for an "end must be after start" key.What alternative solutions did you explore? (Optional)
Localizing
CONST.TIME_PERIODso the toggle emits the locale's meridiem and leavingparse/formaton the global locale. It's internally coherent but more invasive, and it has a concrete failure:StyleUtils.getStatusAMandPMButtonStyle(amPmValue)(TimePicker.tsx L711) decides which button is highlighted by comparingamPmValueagainst the EnglishCONST.TIME_PERIODvalues — swap those for localized strings and the selected-state highlight silently breaks. Pinning the internal representation to English is the smaller, lower-risk change.I also considered dropping the process-wide
setDefaultOptionsin IntlStore.ts#L205 and passing locale explicitly at display sites. That's the actual global source of the mismatch, but every date/relative-time render in the app depends on that default — removing it to fix one picker would regress formatting app-wide. Rejected as far out of scope.oqildev commentedon Aug 7, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-08-07 04:58:00 UTC.
Proposal
Updated: added a compare branch with the implementation, regression tests across all 11 locales, and an empirical comparison of the candidate fixes. The root cause below is unchanged from my original proposal; the solution section is narrowed after testing showed the broader variant regresses localized display.
What is the root cause of that problem?
The time picker emits the hardcoded English meridiem tokens
AM/PM, but every internalhh:mm aparse inDateUtilsruns against the process-widedate-fnslocale. For a German user those two disagree, the parse returnsInvalid Date, and the time silently collapses to''— which then trips the range check and surfaces the wrong "future time" error.Walking the chain:
When the language loads,
IntlStoresets a globaldate-fnslocale viasetDefaultOptions({locale: dateUtilsLocale})—defor German. From then on, every bareparse()/format()inDateUtilsuses German, whose meridiem matcher only acceptsvorm./nachm., notAM/PM.The AM/PM buttons set
amPmValueto the fixed EnglishCONST.TIME_PERIOD.AM/PM(button handlers; there is also an auto-switch toPMwhen an hour > 12 is typed, at L304-L309). So the moment the user taps AM or PM,handleSubmitproduces"08:00 AM"/"02:00 PM"— English tokens, under a German locale.That string flows into
TimeModalPicker.updateInput → DateUtils.combineDateAndTime(time, value). Inside,parse(updatedTime, 'hh:mm a', new Date())matchesAMagainst the German meridiem set, fails, and the function returns''.updateInputwrites that''straight into the form field — the value is destroyed at picker-submit time, not at validation.On save,
IOURequestStepTime.validatere-combines the (now empty) times, andisValidStartEndTimeRangeevaluatesisAfter(new Date(''), new Date(''))→false, reusing thecommon.error.invalidTimeShouldBeFuturekey. The copy is misleading: the future check is explicitly disabled here viashouldValidateFutureTime={false}; the check that actually fails is the empty-string start-before-end comparison.Exactly which locales are affected. I ran
format/parseunder all 11 localesIntlStorecan install:parse('02:00 PM', 'hh:mm a')→ Invalid Date (save is broken)format(d, 'hh:mm a')→ notAM/PM(period token is localized)Japanese is the interesting case: its
formatyields午後, but its matcher also acceptsam/pm, so saving works while the period state is still wrong.Second, visible symptom from the same cause. Before the user touches anything,
amPmValueis seeded fromget12HourTimeObjectFromDate(...).period=format(parsedTime, 'a').toUpperCase()→"NACHM.".getStatusAMandPMButtonStylecompares that againstCONST.TIME_PERIOD, so in de/el/ja/zh-hans neither AM nor PM renders as selected — which is precisely what pushes the user to tap a button and trigger the save failure.Also affected: Profile → Status → Clear after → custom time (
SetTimePage.tsx#L26) shares these helpers, and thereshouldValidateFutureTimedefaults totrue, so the same message appears inside the picker. Per Diem is hit on both the create and the edit step.What changes do you think we should make in order to solve the problem?
My original proposal said the internal 12-hour representation should be pinned to a fixed English locale, and named the four
hh:mm acall sites. Implementing it showed the thesis is right but one of those call sites is doing two jobs:extractTime12Hourproduces both the valueTimePickerholds and the row the user reads. Pinning it fixes the save but turnsμ.μ.→PMfor Greek,午後→PMfor Japanese and下午→PMfor Chinese.So the fix is to split those two roles, and pin only the machine-readable one.
1.
DateUtils— one formatter per role.extractTime12Hourbehaves exactly as it does today, so every user-facing row and every translation is untouched.2.
TimePickerseeds from the internal formatter.3.
get12HourTimeObjectFromDatereads it back with the same locale, and guards the result.Without the guard a failed parse reaches
format(parsedTime, 'hh')and throwsRangeError: Invalid time value, taking the picker down on mount. Pinning the period is what restores the AM/PM button highlight.4.
combineDateAndTime— parse the picker's value with the same locale.enGBrather thanenUSbecause that is the exact date-fns localeIntlStoreloads for English (IntlStore.ts#L77-L87), so English behaviour is unchanged. I checked every caller ofcombineDateAndTime—TimeModalPicker,SetTimePage,IOURequestStepTime(×4),SetDatePageandisTimeAtLeastOneMinuteInFuture— and none reaches thehh:mm abranch with a localized string;SetDatePageand theIOURequestStepTimeform values take theyyyy-MM-dd HH:mm:ssbranch, which has no locale-sensitive token.Net effect: the picker → form → validation pipeline is English end to end, with no
format→parseround trip through the display locale left anywhere in it. Every display path keeps following the language.Compare branch and test evidence
Compare branch: https://github.com/Expensify/App/compare/main...oqildev:App:fix/97796-per-diem-time-locale?expand=1
srcchange is +57 / −18 across two files, most of it comments and the two thin wrappers. No form, API, Onyx or translation changes.I added coverage to
tests/unit/DateUtilsTest.tsthat loads each language throughIntlStoreand replays the picker flow, parameterised over all 11 locales. The regression block deliberately uses only helpers that already exist, so it can be run againstmainunchanged:maincombineDateAndTimeparses the picker outputget12HourTimeObjectFromDatereturns aCONST.TIME_PERIODtoken (button highlight)The last two rows of the table are the ones worth pointing at: they pass on
mainand after the fix. Those are the two cases a format-side pin breaks — the untouched-period path, and localized display — and they are why this change is narrower than my original one.Full
DateUtilsTest: 149/149 green.CalendarPickerTest,TimeTrackingUtilsTest,getTimeDifferenceIntervalsTest: 51/51. ESLint: 0 errors.tsc: error set identical toupstream/main, so this adds none.Follow-ups I found while implementing, but deliberately left out of scope
common.error.invalidTimeShouldBeFutureis the wrong copy on this screen — the check is start-before-end, not a future check. Fixing it needs a new key in 11 languages, so it belongs in its own issue.TimePicker.handleSubmitdrops the day period entirely on theshowFullFormatpath (L731-L738 emits"11:10:10.123"with noatoken), socombineDateAndTimereturns''and validation always fails. Locale-independent — it fails in English too — and today only the Debug date-time page passesshowFullFormat, so it is a separate, non-user-facing bug.PerDiemRequestUtils.formatDateTimeTo12Hour, alongsideDateUtils.formatDateTimeTo12Houron the status page. This change leaves all of them on the global locale, so they stay consistent with each other. Worth noting for any proposal that re-localizes the picker row throughtranslate('common.am'/'common.pm'): that only covers two of the four, and would leave a Greek user readingμμ.on the picker row andμ.μ.on the Per Diem confirmation row of the same flow.What alternative solutions did you explore? (Optional)
Pin the format side too (
extractTime12Hour,formatDateTimeTo12Hour) so the whole 12-hour string is English. This is my original proposal. It works functionally, but as shown above it turns four user-facing display paths English in el/ja/zh-hans, or forces a translate-based re-localization that only covers two of them. Rejected in favour of the narrower change.Localize
CONST.TIME_PERIODso the toggle emits the locale's meridiem. Rejected:getStatusAMandPMButtonStylecomparesamPmValueagainst the English constants, so localizing the stored token silently breaks the selected-state highlight, and date-fns' tokens (vorm.,π.μ.,上午) are not the app's own translations, which would mean keeping a second token table in sync.Fix it only in
IOURequestStepTime.validate. Rejected: the value is already''by the timevalidateruns — it was destroyed inTimeModalPicker.updateInput. Patchingvalidatewould leave the picker writing empty times everywhere elsecombineDateAndTimeis used.Keep the localized round trip and only normalize the period numerically (
parsedTime.getHours() >= 12 ? PM : AM), leavingextractTime12Hourto seed the picker. I built this first and it does pass every test above, with a smaller diff. I moved away from it because it keeps the picker's seed flowing throughformatunder one locale andparseunder the same locale, so it stays correct only as long as every date-fns locale's formatter and matcher are symmetric. I verified they are for all 11 locales today, but that is an assumption about vendored locale data, not something our code enforces — and it is the exact assumption that broke here. Splitting the roles removes the round trip instead of relying on it.Move the picker contract to 24-hour
HH:mm. The most locale-proof shape, and it removes the meridiem token from the wire format entirely. Rejected for this bug: it touches everyTimePickerconsumer plus theshowFullFormatpath, for the same user-facing result. A good follow-up cleanup, but more churn than this bug warrants.Drop the global
setDefaultOptionsinIntlStore. That is the true source of the ambient dependency, but every date and relative-time render in the app relies on it. Far out of scope.samarroy84 commentedon Aug 7, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
A German user cannot save a Per Diem expense. After picking a start/end time, the form fails validation with the misleading error "Please choose a time at least one minute ahead".
What is the root cause of that problem?
The time picker's internal 12-hour strings are parsed and formatted through the user's display locale, but the AM/PM tokens they contain are hardcoded English. In locales whose date-fns meridiem tokens aren't
AM/PM, that parse fails and the time silently becomes an empty string.Step by step:
IntlStoresets a process-wide date-fns locale viasetDefaultOptions({locale: dateUtilsLocale}). Every unqualifiedformat/parsecall in the app now runs under that locale.CONST.TIME_PERIODwhenever the user taps the AM/PM buttons (TimePicker.tsx#L750, #L762) or types an hour > 12 (#L308) — soonSubmitreceives e.g."02:00 PM"regardless of language.DateUtils.combineDateAndTimeparses that withparse(updatedTime, 'hh:mm a', new Date())and passes no locale. Underde, theatoken only matchesvorm./nachm., so the parse returnsInvalid Dateand the function returns''.''is whatTimeModalPicker.updateInputwrites into the form field, so the Per Diem start/end times are stored empty.IOURequestStepTime.validateends up callingisAfter(new Date(''), new Date(''))→false, and showscommon.error.invalidTimeShouldBeFuture.Verified against the shipped locale data.
de'smatchDayPeriodPatternsis/^(vorm\.?|nachm\.?|Mitternacht|Mittag|morgens|abends|nachts)/i—AM/PMsimply don't match. Runningparse('02:00 PM', 'hh:mm a', new Date())under each of the app's 11 locales:parse('02:00 PM')failsformat(date, 'a')is notAM/PMSo this is not German-specific — Greek and Chinese are broken the same way, and Japanese is affected by the
formathalf.Why it looks intermittent, and why the user has to touch the AM/PM buttons.
amPmValueis seeded fromget12HourTimeObjectFromDate(...).period, which isformat(parsedTime, 'a').toUpperCase()(DateUtils.ts#L623) →"VORM."in German. That value round-trips fine, so not touching the period actually works. ButgetStatusAMandPMButtonStylecomparesamPmValueagainstCONST.TIME_PERIOD, so with"VORM."neither button is highlighted — the user naturally taps one to confirm the period, which swaps in the English token and breaks the parse. That's a second, visible bug from the same root cause.Additional beyond Per Diem. Custom Status → Clear after → SetTimePage.tsx#L26 uses the same
combineDateAndTimepath and is broken identically in de/el/zh-Hans.What changes do you think we should make in order to solve the problem?
These 12-hour strings are an internal, machine-readable representation produced and consumed by our own code with fixed
CONST.TIME_PERIODtokens. They should never be interpreted through the user's display locale. So: pin the whole internal round-trip to a fixed locale, and translate the meridiem only at the point of display.1.
src/libs/DateUtils.ts— pin the internal round-tripextractTime12Hour(the producer):format(date, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', {locale: INTERNAL_TIME_LOCALE})combineDateAndTime(the consumer, #L565):parse(updatedTime, 'hh:mm a', new Date(), {locale: INTERNAL_TIME_LOCALE})get12HourTimeObjectFromDate— pin the parse, guard the result, and derive the period from the hour instead of formatting it:Deriving the period numerically also fixes the unhighlighted AM/PM buttons, since
amPmValueis now alwaysAM/PM.isValidguard is required, not defensive polish. Without it, a failed parse reachesformat(parsedTime, 'hh'), which throwsRangeError: Invalid time value. Any partial version of this fix that pinsget12HourTimeObjectFromDatebut leavesextractTime12Hourlocalized will crash the time picker on mount in German, becauseextractTime12Hourwould still hand it"12:00 vorm.". Producer and consumer have to move together.2. Keep display localized — add a display-only helper
Use it at the two places that show a time to the user — TimeModalPicker.tsx#L38 and StatusClearAfterPage.tsx#L131 — both of which already have
translatein scope.Without this step, pinning
extractTime12Hourwould regress display for Greek (π.μ./μμ.), Japanese and Chinese users, who'd start seeing EnglishAM/PM. Routing display throughcommon.am/common.pminstead of date-fns has the added benefit that the displayed time now always matches the picker's own button labels, which is not true today.Out of scope but worth a follow-up:
common.error.invalidTimeShouldBeFutureis the wrong copy for this screen regardless of this bug —TimeModalPickersetsshouldValidateFutureTime={false}, so the real check is start-before-end, not a future check. Fixing it means a new translation key across 11 languages, which belongs in its own issue.What alternative solutions did you explore? (Optional)
Localize
CONST.TIME_PERIODand parse everything against the current locale. Rejected — it makes a machine-readable value locale-dependent, spreads locale awareness across everyhh:mm acall site, and any locale where date-fns'format/parseday-period tokens aren't perfectly symmetric would break silently. Pinning the internal representation is smaller and strictly more robust.Pin only
combineDateAndTime. Rejected — it fixes saving but leavesget12HourTimeObjectFromDatereturning"VORM.", so the AM/PM buttons stay unhighlighted and the underlying inconsistency remains one refactor away from returning.Normalize the period numerically but leave everything else on the global locale (i.e. rely on
format/parsebeing symmetric per locale). I verified this does round-trip correctly in all 11 locales today, and it's a smaller diff — but it keeps the internal path dependent on mutable global state, which is exactly the defect class that caused this bug. Not worth the saved lines.50 remaining items
MelvinBot commentedon Aug 18, 2026
Offending PR: #64619 — "feat: handle lazy load date fns locales" (merged 2025-06-24, author
linhvovan29546).That PR started calling
setDefaultOptions({locale: dateUtilsLocale})for every supported language — including German — as part of the lazy date-fns loader. From that merge on, selecting German makes the process‑wide date‑fns meridiemvorm./nachm., which the picker's hardcoded EnglishAM/PMtokens can no longer parse →combineDateAndTimereturns''→ the misleadinginvalidTimeShouldBeFutureerror on the Per Diem time step.Heads up: this is a latent interaction bug, not a clean single-PR break — so pick where you comment based on what BugZero is after:
shubham1206agra). It introduced the locale-lessparse(updatedTime, 'hh:mm a', new Date())incombineDateAndTimeand the start/end validation — but it was harmless on its own.roryabraham) newly exposed the same break for Greek. (German/Chinese were already wired back in feat: handle lazy load date fns locales #64619.)Evidence chain
DateUtils.setLocaleonly wiredenGB/esand had adefault: break, so every other language fell back to date-fns' built-inenUS, whereAM/PMparse fine. So German never triggered the bug.d4cee82ba926,src/languages/TranslationStore.ts): addedconst dateUtilsLocale = this.dateUtilsCache.get(locale); if (dateUtilsLocale) setDefaultOptions({locale: dateUtilsLocale})and wired real date-fns locales forde,fr,it,ja,nl,pl, … German's meridiem (vorm./nachm.) became the process-wide default the moment a user selects German. Verified in thed4cee82ba926blob:setDefaultOptionsimport,dateUtilsCache, andimport('date-fns/locale/de').83128dd7,src/libs/DateUtils.ts):combineDateAndTimeparses the picker's fixed EnglishAM/PMwith a locale-lessparse(…, 'hh:mm a'), andIOURequestStepTimeruns the start/end range check.TranslationStore.ts→IntlStore.ts(thesetDefaultOptionsline the proposals point at). Note: blaming that line today points to an unrelated drop-then-restore from [Due for payment 2026-08-21] Spends - Expense date keep Greek spelling after changing the language to English #97781 work (commit29d57e26, 2026-08-08) — that's why the line blame is misleading; the mechanism traces back to feat: handle lazy load date fns locales #64619.melvin-bot commentedon Aug 24, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Aug 24, 2026
Payment Summary
Resolving PRs:
Keep the time picker's AM/PM period locale-independent #98110
Reviewer: @mukhrr owed $250 via NewDot
Reviewer: @aimane-chnaif owed $250 via NewDot
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Aug 26, 2026
Payment Summary
Contributor: @mukhrr paid $250 via Upwork
Contributor+: @aimane-chnaif due $250 via NewDot
flaviadefaria commentedon Sep 2, 2026
Approved: @mukhrr due $250 via NewDot
garrettmknight commentedon Sep 28, 2026
$250 approved for @aimane-chnaif