Skip to content

[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

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


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:

  1. Set phone timezone/location and New Expensify language to German
  2. Upload Per Diem rates
  3. Create Per Diem - the default is at 12:00 AM (start) and 12:00 PM (end) times
  4. Try switching start time to 08:00 AM
  5. Try switching end time to 02:00 PM
  6. 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:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

View all open jobs on GitHub

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Aug 4, 2026
  2. MelvinBot commented on Aug 4, 2026

    @MelvinBot
    Contributor

    Proposal

    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.

    What is the root cause of that problem?

    1. When the language loads, IntlStore sets a process-wide date-fns default locale via setDefaultOptions({locale: dateUtilsLocale}) (e.g. de for German).
    2. The Per Diem time picker always produces hardcoded English AM/PM strings — CONST.TIME_PERIOD = {AM: 'AM', PM: 'PM'} — so the picker outputs e.g. "08:00 AM" / "02:00 PM" regardless of language.
    3. 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 ''.
    4. 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.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Aug 4, 2026
  4. melvin-bot commented on Aug 4, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @aimane-chnaif (External)

  5. melvin-bot commented on Aug 4, 2026

    @melvin-bot
  6. trjExpensify commented on Aug 5, 2026

    @trjExpensify
    Contributor

    @aimane-chnaif what do you think of Melvin's proposal? Per Diem is super popular in Germany, so we should get this fixed ASAP.

  7. aimane-chnaif commented on Aug 6, 2026

    @aimane-chnaif
    Contributor

    @MelvinBot draft PR. Checking if your solution works.

  8. aimane-chnaif commented on Aug 7, 2026

    @aimane-chnaif
    Contributor

    Melvin isn't responsive.
    🆘 C+ Help Wanted

  9. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Aug 7, 2026
  10. Abdulloh0109 commented on Aug 7, 2026

    @Abdulloh0109
    Contributor

    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-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:

    1. 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.
    2. 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.
    3. 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".
    4. 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 ''.
    5. 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:

    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.

  11. oqildev commented on Aug 7, 2026

    @oqildev
    Contributor

    🚨 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-wide date-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:

    1. 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.

    2. 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.

    3. That string flows into TimeModalPicker.updateInput → DateUtils.combineDateAndTime(time, value). Inside, parse(updatedTime, 'hh:mm a', new Date()) matches AM against the German meridiem set, fails, and the function returns ''. updateInput writes that '' straight into the form field — the value is destroyed at picker-submit time, not at validation.

    4. 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.

    function formatTime12Hour(dateTimeString: string, isFullFormat: boolean, locale?: DateFnsLocale): string {
        if (!dateTimeString || dateTimeString === 'never') {
            return '';
        }
        const date = new Date(dateTimeString);
        return format(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. */
    function extractTime12Hour(dateTimeString: string, isFullFormat = false): string {
        return formatTime12Hour(dateTimeString, isFullFormat);
    }
    
    /** Formats the time that TimePicker keeps as its value: machine readable, so pinned to English. */
    function extractInternalTime12Hour(dateTimeString: string, isFullFormat = false): string {
        return formatTime12Hour(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.

    Compare branch and test evidence

    Compare branch: https://github.com/Expensify/App/compare/main...oqildev:App:fix/97796-per-diem-time-locale?expand=1

    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 ❌ ✅
    main:  Tests: 15 failed, 42 passed
    fix:   Tests: 57 passed
    

    The last two rows of the table are the ones worth pointing at: they pass on main and 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

    1. 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.
    2. 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.
    3. 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.

  12. samarroy84 commented on Aug 7, 2026

    @samarroy84
    Contributor

    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:

    1. 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.
    2. 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.
    3. 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 ''.
    4. That '' is what TimeModalPicker.updateInput writes into the form field, so the Per Diem start/end times are stored empty.
    5. 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.
     */
    const INTERNAL_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:
    function get12HourTimeObjectFromDate(dateTime: string, isFullFormat = false): {hour: string; minute: string; seconds: string; milliseconds: string; period: string} {
        const defaultTimeObject = {hour: '12', minute: '00', seconds: '00', milliseconds: '000', period: CONST.TIME_PERIOD.PM};
        if (!dateTime) {
            return defaultTimeObject;
        }
        const parsedTime = parse(dateTime, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', new Date(), {locale: INTERNAL_TIME_LOCALE});
        if (!isValid(parsedTime)) {
            return defaultTimeObject;
        }
        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
     */
    function getLocalizedTime12Hour(translate: LocalizedTranslate, dateTimeString: string, isFullFormat = false): string {
        const time = extractTime12Hour(dateTimeString, isFullFormat);
        if (!time) {
            return '';
        }
        return time.replace(CONST.TIME_PERIOD.AM, translate('common.am')).replace(CONST.TIME_PERIOD.PM, translate('common.pm'));
    }

    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 translate in scope.

    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.

  13. 50 remaining items

  14. MelvinBot commented on Aug 18, 2026

    @MelvinBot
    Contributor

    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 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:

    Evidence chain
  15. added
    ReviewingHas a PR in review
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Aug 21, 2026
  16. melvin-bot commented on Aug 24, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  17. melvin-bot commented on Aug 24, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  18. mallenexpensify commented on Aug 26, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @mukhrr paid $250 via Upwork
    Contributor+: @aimane-chnaif due $250 via NewDot

  19. flaviadefaria commented on Sep 2, 2026

    @flaviadefaria
    Contributor

    Approved: @mukhrr due $250 via NewDot

  20. garrettmknight commented on Sep 28, 2026

    @garrettmknight
    Contributor

    $250 approved for @aimane-chnaif

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in review

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions