Skip to content

Conversation

@jakubkalinski0
Copy link
Contributor

@jakubkalinski0 jakubkalinski0 commented Dec 12, 2025

Explanation of Change

This PR adds an Odometer option to the distance request flow allowing user to add odometer readings at the beginning and end of the trip. This explanation and implementation is mapped according to docs (only Phase 1; selected items from 1.A–1.J). Some code fragments are added in the explanation below for clarity. This PR introduces end-to-end handling of this feature allowing to create an actual distance-odometer expense. This PR only allows to add odometer readings without capturing and merging images or the 'Save for later' functionality.

  • 1.A/1.B (new tab + odometer page): New distance-odometer tab and odometer step route. TabSelector uses the meter icon, and DistanceRequestStartPage renders the odometer screen:
    • Tab selector entry:
case CONST.TAB_REQUEST.DISTANCE_ODOMETER:
    return {icon: icons.Meter, title: translate('tabSelector.odometer'), testID: 'distanceOdometer'};
  • Odometer screen mounted in distance start tabs:
<TopTab.Screen name={CONST.TAB_REQUEST.DISTANCE_ODOMETER}>
    {() => (
        <TabScreenWithFocusTrapWrapper>
            <IOURequestStepDistanceOdometer
                route={route}
                navigation={navigation}
            />
        </TabScreenWithFocusTrapWrapper>
    )}
</TopTab.Screen>
  • 1.C/1.F (validation + live total + errors): Odometer step enforces both readings and end > start before navigation:
if (!startReading || !endReading) {
    setFormError(translate('distance.odometer.readingRequired'));
    return;
}
const distance = end - start;
if (distance <= 0) {
    setFormError(translate('distance.odometer.negativeDistanceNotAllowed'));
    return;
}
navigateToNextPage();
  • 1.E (Discard Changes): Reuses DiscardChangesConfirmation to block accidental loss of unsaved odometer inputs when leaving the screen:
<DiscardChangesConfirmation
    isEnabled={shouldEnableDiscardConfirmation}
    getHasUnsavedChanges={() => {
        const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current;
        return hasReadingChanges;
    }}
/>;
  • 1.J (edit implementation): When editing, the button becomes “Save”. We update distance only if readings changed, then return to the prior screen:
if (isEditing) {
    const hasChanges = previousDistance !== calculatedDistance || previousStart !== start || previousEnd !== end;
    if (hasChanges) {
        updateMoneyRequestDistance({ ... });
    }
    if (isEditingConfirmation) {
        allowNavigation();
    }
    Navigation.goBack(confirmationRoute ?? undefined);
    return;
}
  • 1.F/1.J (distance calc + confirm totals): On Next/Save we store odometerStart/odometerEnd into the transaction comment and compute/set total distance into the custom unit; creation/tracking flows pass odometer values through to the backend:
setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft);
const distance = end - start;
const calculatedDistance = roundToTwoDecimalPlaces(distance);
setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft);
odometerStart: isOdometerDistanceRequest ? item.comment?.odometerStart : undefined,
odometerEnd: isOdometerDistanceRequest ? item.comment?.odometerEnd : undefined,
  • 1.J (confirm details): The confirmation list/footer recognizes odometer distance, hides map previews, and routes the distance row back to the odometer step for edits:
listFooterContent={
    <MoneyRequestConfirmationListFooter
        ...
        isOdometerDistanceRequest={isOdometerDistanceRequest}
if (isOdometerDistanceRequest) {
    Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(action, iouType, transactionID, reportID, true) as Route);
    return;
}
  • Default policy handling: For create/track flows on a paid personal policy, we auto-select the default expense policy and rate so odometer distance gets a rate without extra user input:
const shouldUseDefaultExpensePolicy = useMemo(
    () =>
        iouType === CONST.IOU.TYPE.CREATE &&
        isPaidGroupPolicy(defaultExpensePolicy) &&
        defaultExpensePolicy?.isPolicyExpenseChatEnabled &&
        !shouldRestrictUserBillableActions(defaultExpensePolicy.id),
    [iouType, defaultExpensePolicy],
);

Fixed Issues

$ #77191
PROPOSAL: N/A

Tests

Odometer Distance Tests

  1. Global Create -> Track distance -> Odometer tab

    • Open the new Odometer tab.
    • Verify Start/End inputs render and tab icon/title match the translation.
  2. Missing fields validation and End < Start validation

    • Leave either Start or End empty and press Next.
    • Expect “Please enter both start and end readings”; stay on the page.
    • Enter Start=200, End=150, press Next.
    • See “End reading must be greater than start reading”; no navigation.
  3. Correct input 100 -> 160

    • Enter Start=100, End=160; total shows 60 in the selected unit.
    • Press Next: land on recipient page and choose one
    • Land on confirmation page with distance/amount updated.
    • Press Save on confirmation (no edits): no errors, return correctly.
  4. Back/unsaved changes - Discard Changes modal

    • Enter/change readings, hit browser back.
    • Discard Changes modal appears. Choose Discard: readings clear; RHP closes.
  5. Tab switching state

    • Enter readings, switch Map <-> Manual <-> Odometer.
    • Odometer inputs reset only when transaction state is cleared on tab switch; no stale values.
  6. Edit on confirmation (test errors, increase distance, test leave without saving)

    • On confirmation, tap Distance, increase readings, Save.
    • Confirmation shows updated distance/amount and Start/End readings.
    • Once again tap Distance, remove readings, Save.
    • Error shows up.
    • Tap distance, change End > Start, Save.
    • Error shows up.
    • Ta distance, change something, tap outside of RHP or '<' button.
    • Discard changes modal doesn't show up and the changes aren't saved; land on confirmation page.
  7. Existing report (non-global create)

    • Create odometer distance from an existing report (i.e. from a chat).
    • Participants should be auto-selected.
    • Confirmation shows odometer distance.
  8. Skip-confirmation path

    • Create an odometer expense, then FAB -> Track distance (bottom).
    • Enter valid readings, press Next.
    • Expense is created immediately (no confirmation) with odometerStart/End and distance set.
  9. Default expense policy

    • In a paid personal policy with distance rates and auto-reporting, start Odometer via Global Create.
    • After entering readings, confirmation/creation applies the default policy rate automatically (amount matches; no manual rate selection UI).
  • Verify that no errors appear in the JS console

Offline tests

Disconnect internet from your PC or force offline through troubleshoot

  1. create a correct odometer expense through FAB and verify if it is pending
  2. connect to internet and verify that the pending expense is created correctly

Everything else should work same as described in Tests

QA Steps

Basically same as Tests

  1. Follow Tests 1–10 for core creation, validation, tab switching, edit loop, translations, and base flow.
  2. Follow Tests 11–18 for skip-confirmation behavior (happy/validation), DiscardChangesConfirmation behavior, default-policy rate application, edit-from-confirmation, map suppression for odometer, and distance regression coverage (map/manual).
  3. Regression: Verify map/manual distance flows still create expenses and work properly.
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
1to4.mov
5to6.mov
7to8.mov

@codecov
Copy link

codecov bot commented Dec 12, 2025

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/CONST/index.ts 83.47% <ø> (ø)
src/SCREENS.ts 100.00% <ø> (ø)
src/libs/DebugUtils.ts 68.43% <ø> (ø)
src/libs/Navigation/linkingConfig/config.ts 75.00% <ø> (ø)
...iou/request/step/withFullTransactionOrNotFound.tsx 92.85% <ø> (ø)
.../iou/request/step/withWritableReportOrNotFound.tsx 88.23% <ø> (ø)
src/components/MoneyRequestConfirmationList.tsx 58.31% <0.00%> (-0.15%) ⬇️
.../components/MoneyRequestConfirmationListFooter.tsx 83.95% <80.00%> (-0.32%) ⬇️
...gation/AppNavigator/ModalStackNavigators/index.tsx 8.25% <0.00%> (-0.02%) ⬇️
...ages/iou/request/IOURequestRedirectToStartPage.tsx 0.00% <0.00%> (ø)
... and 11 more
... and 10 files with indirect coverage changes

@melvin-bot
Copy link

melvin-bot bot commented Dec 12, 2025

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx ts-node ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx ts-node ./scripts/generateTranslations.ts --compare-ref main

@jakubkalinski0
Copy link
Contributor Author

jakubkalinski0 commented Dec 12, 2025

TODO:

  • Adjust navigation to align with the new backTo handling
  • Add translations for other languages
  • Cleanup the code
  • Verify if the Odometer expense creation aligns properly with BE
  • Fill out the checklist, add change explanation and thorough tests with detailed expected behaviour description
  • Most obvious one - fix failing checks, duh

@jakubkalinski0 jakubkalinski0 changed the title [ON HOLD] [Odometer] Create NewDot Odometer expense flow [Odometer] Create NewDot Odometer expense flow Dec 16, 2025
@jakubkalinski0
Copy link
Contributor Author

Update:
Working on proper editing of an existing odometer expense

@DylanDylann DylanDylann removed their assignment Dec 17, 2025
@jakubkalinski0 jakubkalinski0 force-pushed the jakubkalinski0/Create_NewDot_Odometer_expense_flow branch from f93eaa6 to 581d8f1 Compare December 19, 2025 14:16
@jakubkalinski0
Copy link
Contributor Author

jakubkalinski0 commented Dec 22, 2025

Update: currently working on skipConfirmation logic - for some reason it changes to manual expense after 1 use

Screen.Recording.2025-12-22.at.20.45.14.mov

@jakubkalinski0 jakubkalinski0 force-pushed the jakubkalinski0/Create_NewDot_Odometer_expense_flow branch from 93c67a0 to 612f908 Compare December 23, 2025 00:11
@Julesssss
Copy link
Contributor

currently working on skipConfirmation logic - for some reason it changes to manual expense after 1 use

@jakubkalinski0 to confirm, do you mean the Quick action button? Could you confirm the issue. I see that is shows 'Track Distance', but you land on the Odometer page.

@jakubkalinski0
Copy link
Contributor Author

Now only failing typecheck comes from the missing translations. I am unsure how the procedure of adding the missing translations looks now since it was a while since I had to add any. @Julesssss I think that now there is some kind of script that someone else has to trigger? Is that correct? I am not totally sure how does that look like now

@OSBotify
Copy link
Contributor

🦜 Polyglot Parrot! 🦜

Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues:

View the translation diff
diff --git a/src/languages/de.ts b/src/languages/de.ts
index ab60aa4b..340a31a9 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1012,15 +1012,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Abonnement',
         domains: 'Domänen',
     },
-    tabSelector: {
-        chat: 'Chat',
-        room: 'Raum',
-        distance: 'Entfernung',
-        manual: 'Manuell',
-        scan: 'Scannen',
-        map: 'Karte',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Chat', room: 'Raum', distance: 'Entfernung', manual: 'Manuell', scan: 'Scannen', map: 'Karte', gps: 'GPS', odometer: 'Kilometerzähler'},
     spreadsheet: {
         upload: 'Eine Tabellenkalkulation hochladen',
         import: 'Tabellenkalkulation importieren',
@@ -1364,6 +1356,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Satz für diesen Workspace ungültig. Bitte wählen Sie einen verfügbaren Satz aus dem Workspace aus.',
             endDateBeforeStartDate: 'Das Enddatum darf nicht vor dem Startdatum liegen',
             endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum identisch sein',
+            odometerReadingRequired: 'Bitte geben Sie sowohl Start- als auch Endstand ein',
+            negativeDistanceNotAllowed: 'Endstand muss größer als Startstand sein',
         },
         dismissReceiptError: 'Fehler ausblenden',
         dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler verwirfst, wird dein hochgeladener Beleg vollständig entfernt. Bist du sicher?',
@@ -7208,6 +7202,14 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard
         error: {
             selectSuggestedAddress: 'Bitte wählen Sie eine vorgeschlagene Adresse aus oder verwenden Sie den aktuellen Standort',
         },
+        odometer: {
+            startReading: 'Lesen starten',
+            endReading: 'Lesen beenden',
+            saveForLater: 'Für später speichern',
+            totalDistance: 'Gesamtstrecke',
+            readingRequired: 'Start- und Endzählerstände sind erforderlich',
+            negativeDistanceNotAllowed: 'Endstand muss größer als Startstand sein',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Zeugnis verloren oder beschädigt',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index a9b10cb4..153f80b0 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1014,15 +1014,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Abonnement',
         domains: 'Domaines',
     },
-    tabSelector: {
-        chat: 'Discussion',
-        room: 'Salle',
-        distance: 'Distance',
-        manual: 'Manuel',
-        scan: 'Scanner',
-        map: 'Carte',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Discussion', room: 'Salle', distance: 'Distance', manual: 'Manuel', scan: 'Scanner', map: 'Carte', gps: 'GPS', odometer: 'Compteur kilométrique'},
     spreadsheet: {
         upload: 'Téléverser une feuille de calcul',
         import: 'Importer une feuille de calcul',
@@ -1365,6 +1357,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Taux non valide pour cet espace de travail. Veuillez sélectionner un taux disponible dans l’espace de travail.',
             endDateBeforeStartDate: 'La date de fin ne peut pas être antérieure à la date de début',
             endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début',
+            odometerReadingRequired: 'Veuillez saisir les relevés de début et de fin',
+            negativeDistanceNotAllowed: 'Le relevé de fin doit être supérieur au relevé de début',
         },
         dismissReceiptError: 'Ignorer l’erreur',
         dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera entièrement votre reçu téléchargé. Êtes-vous sûr ?',
@@ -7217,6 +7211,14 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin
         error: {
             selectSuggestedAddress: 'Veuillez sélectionner une adresse suggérée ou utiliser la position actuelle',
         },
+        odometer: {
+            startReading: 'Commencer la lecture',
+            endReading: 'Terminer la lecture',
+            saveForLater: 'Enregistrer pour plus tard',
+            totalDistance: 'Distance totale',
+            readingRequired: 'Les relevés de début et de fin sont requis',
+            negativeDistanceNotAllowed: 'Le relevé de fin doit être supérieur au relevé de début',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Bulletin perdu ou endommagé',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 0e56b341..a5678720 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1010,15 +1010,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Abbonamento',
         domains: 'Domini',
     },
-    tabSelector: {
-        chat: 'Chat',
-        room: 'Stanza',
-        distance: 'Distanza',
-        manual: 'Manuale',
-        scan: 'Scannerizza',
-        map: 'Mappa',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Chat', room: 'Stanza', distance: 'Distanza', manual: 'Manuale', scan: 'Scannerizza', map: 'Mappa', gps: 'GPS', odometer: 'Contachilometri'},
     spreadsheet: {
         upload: 'Carica un foglio di calcolo',
         import: 'Importa foglio di calcolo',
@@ -1359,6 +1351,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Tariffa non valida per questo workspace. Seleziona una tariffa disponibile dal workspace.',
             endDateBeforeStartDate: 'La data di fine non può essere precedente alla data di inizio',
             endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio',
+            odometerReadingRequired: 'Inserisci sia la lettura iniziale che quella finale',
+            negativeDistanceNotAllowed: 'La lettura finale deve essere maggiore della lettura iniziale',
         },
         dismissReceiptError: 'Ignora errore',
         dismissReceiptErrorConfirmation: 'Attenzione! Se ignori questo errore, la ricevuta caricata verrà rimossa completamente. Sei sicuro?',
@@ -7191,6 +7185,14 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori
         error: {
             selectSuggestedAddress: 'Seleziona un indirizzo suggerito o usa la posizione attuale',
         },
+        odometer: {
+            startReading: 'Inizia a leggere',
+            endReading: 'Fine lettura',
+            saveForLater: 'Salva per dopo',
+            totalDistance: 'Distanza totale',
+            readingRequired: 'Sono necessarie le letture iniziali e finali',
+            negativeDistanceNotAllowed: 'La lettura finale deve essere maggiore della lettura iniziale',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Pagella smarrita o danneggiata',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 4143febe..d5136ae6 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1010,15 +1010,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'サブスクリプション',
         domains: 'ドメイン',
     },
-    tabSelector: {
-        chat: 'チャット',
-        room: '部屋',
-        distance: '距離',
-        manual: '手動',
-        scan: 'スキャン',
-        map: '地図',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'チャット', room: '部屋', distance: '距離', manual: '手動', scan: 'スキャン', map: '地図', gps: 'GPS', odometer: 'オドメーター'},
     spreadsheet: {
         upload: 'スプレッドシートをアップロード',
         import: 'スプレッドシートをインポート',
@@ -1359,6 +1351,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'このワークスペースでは無効なレートです。ワークスペースから利用可能なレートを選択してください。',
             endDateBeforeStartDate: '終了日は開始日より前にはできません',
             endDateSameAsStartDate: '終了日は開始日と同じにはできません',
+            odometerReadingRequired: '開始時刻と終了時刻の両方を入力してください',
+            negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません',
         },
         dismissReceiptError: 'エラーを閉じる',
         dismissReceiptErrorConfirmation: '注意!このエラーを無視すると、アップロードした領収書が完全に削除されます。本当に実行しますか?',
@@ -7135,6 +7129,14 @@ ${reportName}
         error: {
             selectSuggestedAddress: '候補の住所を選択するか、現在地を使用してください',
         },
+        odometer: {
+            startReading: '読み始める',
+            endReading: '読み終える',
+            saveForLater: '後で保存',
+            totalDistance: '合計距離',
+            readingRequired: '開始値と終了値の入力が必要です',
+            negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: '成績証明書の紛失または損傷',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index d281e100..6f0d3ab4 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1010,15 +1010,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Abonnement',
         domains: 'Domeinen',
     },
-    tabSelector: {
-        chat: 'Chat',
-        room: 'Kamer',
-        distance: 'Afstand',
-        manual: 'Handmatig',
-        scan: 'Scannen',
-        map: 'Kaart',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Chat', room: 'Kamer', distance: 'Afstand', manual: 'Handmatig', scan: 'Scannen', map: 'Kaart', gps: 'GPS', odometer: 'Kilometerteller'},
     spreadsheet: {
         upload: 'Een spreadsheet uploaden',
         import: 'Spreadsheet importeren',
@@ -1358,6 +1350,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Tarief is niet geldig voor deze workspace. Selecteer een beschikbaar tarief uit de workspace.',
             endDateBeforeStartDate: 'De einddatum kan niet vóór de startdatum liggen',
             endDateSameAsStartDate: 'De einddatum mag niet hetzelfde zijn als de startdatum',
+            odometerReadingRequired: 'Voer zowel de begin- als eindstand in',
+            negativeDistanceNotAllowed: 'Eindstand moet groter zijn dan beginstand',
         },
         dismissReceiptError: 'Foutmelding sluiten',
         dismissReceiptErrorConfirmation: 'Let op! Als je deze foutmelding negeert, wordt je geüploade bon volledig verwijderd. Weet je het zeker?',
@@ -7178,6 +7172,14 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten
         error: {
             selectSuggestedAddress: 'Selecteer een voorgesteld adres of gebruik huidige locatie',
         },
+        odometer: {
+            startReading: 'Begin met lezen',
+            endReading: 'Lezen beëindigen',
+            saveForLater: 'Later opslaan',
+            totalDistance: 'Totale afstand',
+            readingRequired: 'Begin- en eindstanden zijn vereist',
+            negativeDistanceNotAllowed: 'Eindstand moet groter zijn dan beginstand',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Rapportkaart kwijt of beschadigd',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 031c5946..440aebef 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1010,15 +1010,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Subskrypcja',
         domains: 'Domeny',
     },
-    tabSelector: {
-        chat: 'Czat',
-        room: 'Pokój',
-        distance: 'Dystans',
-        manual: 'Ręczny',
-        scan: 'Skanuj',
-        map: 'Mapa',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Czat', room: 'Pokój', distance: 'Dystans', manual: 'Ręczny', scan: 'Skanuj', map: 'Mapa', gps: 'GPS', odometer: 'Licznik przebiegu'},
     spreadsheet: {
         upload: 'Prześlij arkusz kalkulacyjny',
         import: 'Importuj arkusz kalkulacyjny',
@@ -1356,6 +1348,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Stawka nie jest prawidłowa dla tego przestrzeni roboczej. Wybierz dostępną stawkę z tej przestrzeni roboczej.',
             endDateBeforeStartDate: 'Data zakończenia nie może być wcześniejsza niż data rozpoczęcia',
             endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia',
+            odometerReadingRequired: 'Wprowadź zarówno odczyt początkowy, jak i końcowy',
+            negativeDistanceNotAllowed: 'Odczyt końcowy musi być większy niż odczyt początkowy',
         },
         dismissReceiptError: 'Odrzuć błąd',
         dismissReceiptErrorConfirmation: 'Uwaga! Odrzucenie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?',
@@ -7167,6 +7161,14 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i
         error: {
             selectSuggestedAddress: 'Wybierz sugerowany adres lub użyj bieżącej lokalizacji',
         },
+        odometer: {
+            startReading: 'Rozpocznij czytanie',
+            endReading: 'Zakończ czytanie',
+            saveForLater: 'Zapisz na później',
+            totalDistance: 'Całkowity dystans',
+            readingRequired: 'Wymagane są odczyty początkowe i końcowe',
+            negativeDistanceNotAllowed: 'Odczyt końcowy musi być większy niż odczyt początkowy',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Świadectwo zgubione lub uszkodzone',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 747e178b..e02419bb 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1009,15 +1009,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: 'Assinatura',
         domains: 'Domínios',
     },
-    tabSelector: {
-        chat: 'Chat',
-        room: 'Sala',
-        distance: 'Distância',
-        manual: 'Manual',
-        scan: 'Escanear',
-        map: 'Mapa',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: 'Chat', room: 'Sala', distance: 'Distância', manual: 'Manual', scan: 'Escanear', map: 'Mapa', gps: 'GPS', odometer: 'Hodômetro'},
     spreadsheet: {
         upload: 'Enviar uma planilha',
         import: 'Importar planilha',
@@ -1356,6 +1348,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: 'Taxa inválida para este workspace. Selecione uma taxa disponível do workspace.',
             endDateBeforeStartDate: 'A data de término não pode ser anterior à data de início',
             endDateSameAsStartDate: 'A data de término não pode ser igual à data de início',
+            odometerReadingRequired: 'Insira as leituras de início e de fim',
+            negativeDistanceNotAllowed: 'A leitura final deve ser maior que a leitura inicial',
         },
         dismissReceiptError: 'Dispensar erro',
         dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o seu recibo enviado. Tem certeza?',
@@ -7171,6 +7165,14 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe
         error: {
             selectSuggestedAddress: 'Selecione um endereço sugerido ou use a localização atual',
         },
+        odometer: {
+            startReading: 'Começar a ler',
+            endReading: 'Finalizar leitura',
+            saveForLater: 'Salvar para depois',
+            totalDistance: 'Distância total',
+            readingRequired: 'Leituras inicial e final são obrigatórias',
+            negativeDistanceNotAllowed: 'A leitura final deve ser maior que a leitura inicial',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: 'Boletim perdido ou danificado',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index d0de96c6..727ed2fb 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -998,15 +998,7 @@ const translations: TranslationDeepObject<typeof en> = {
         subscription: '订阅',
         domains: '域名',
     },
-    tabSelector: {
-        chat: '聊天',
-        room: '房间',
-        distance: '距离',
-        manual: '手动',
-        scan: '扫描',
-        map: '地图',
-        gps: 'GPS',
-    },
+    tabSelector: {chat: '聊天', room: '房间', distance: '距离', manual: '手动', scan: '扫描', map: '地图', gps: 'GPS', odometer: '里程表'},
     spreadsheet: {
         upload: '上传电子表格',
         import: '导入电子表格',
@@ -1336,6 +1328,8 @@ const translations: TranslationDeepObject<typeof en> = {
             invalidRate: '此汇率对该工作区无效。请选择此工作区中的可用汇率。',
             endDateBeforeStartDate: '结束日期不能早于开始日期',
             endDateSameAsStartDate: '结束日期不能与开始日期相同',
+            odometerReadingRequired: '请输入起始读数和结束读数',
+            negativeDistanceNotAllowed: '结束读数必须大于开始读数',
         },
         dismissReceiptError: '忽略错误',
         dismissReceiptErrorConfirmation: '提醒!关闭此错误会完全删除你上传的收据。确定要继续吗?',
@@ -7027,6 +7021,14 @@ ${reportName}
         error: {
             selectSuggestedAddress: '请选择一个建议地址或使用当前位置',
         },
+        odometer: {
+            startReading: '开始阅读',
+            endReading: '结束阅读',
+            saveForLater: '稍后保存',
+            totalDistance: '总距离',
+            readingRequired: '必须提供起始读数和结束读数',
+            negativeDistanceNotAllowed: '结束读数必须大于开始读数',
+        },
     },
     reportCardLostOrDamaged: {
         screenTitle: '成绩单遗失或损坏',

Note

You can apply these changes to your branch by copying the patch to your clipboard, then running pbpaste | git apply 😉

@Julesssss
Copy link
Contributor

Julesssss commented Dec 24, 2025

Testing well for me locally so far 🎉 Ready for another review I think @DylanDylann

Known issues:

  • Quick action changes from distance to regular expense
  • Updating created odometer transactions not working fully, backend PR in review here

@DylanDylann
Copy link
Contributor

Got it

@Julesssss
Copy link
Contributor

Quick action changes from distance to regular expense
@jakubkalinski0 I found the fix for this! Here we are not including Odometer expenses as a type of distance expense:

Screenshot 2025-12-24 at 09 21 36

I removed your workaround and verified this fix. I didn't commit as that would prevent me from being able to final review. We can fix this in the new year quite easily 👍

Updating created odometer transactions not working fully, backend PR in review https://github.com/Expensify/Web-Expensify/pull/49944
PR was merged and will hit staging on the next backend deploy.

@DylanDylann
Copy link
Contributor

@Julesssss @jakubkalinski0 Should we add a feature flag?

Comment on lines +353 to +354
const reportName = (() => {
const name = computeReportName(selectedReport, allReports, allPolicies, undefined, reportNameValuePairs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to make this refactor here? Given the large scope of this PR, we should focus on the feature implementation. It would be better to move this into a separate PR so we can test it more thoroughly

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversed this on the new PR. @jakubkalinski0 when you're back please let us know if this was necessary for some reason!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be completely honest I don't remember the exact reason for this change 😅 I think there was some edge case that didn't work properly but if everything works correctly now then we are fine I guess

@DylanDylann
Copy link
Contributor

Missing update in MoneyRequestView, we need to update when pressing the distance row

Screenshot 2026-01-06 at 14 08 31

Comment on lines +10 to +12
if (!isEnabled) {
return undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you provide more detail on why we should make this change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like maybe the hook should only be active when editing from confirmation page... I'll leave this change.

Then @jakubkalinski0 please take a look and share your thoughts when you are back. Thanks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats exactly the case. We agreed that we don't want DiscardChangesConfirmation to be shown when we are editing from confirmation page and thus change to this hook was needed

@DylanDylann
Copy link
Contributor

DylanDylann commented Jan 6, 2026

The edit flow doesn't work. When update the start and end point, we should save it locally and only call update API in the detail confirmation page

Screen.Recording.2026-01-06.at.14.45.13.mov

@DylanDylann
Copy link
Contributor

Could you also check the optimistic data when creating an odometer expense? When creating it offline, the merchant field is shown instead of the distance field, and there’s an error with the tax field (it’s inconsistent with the BE response)

Screen.Recording.2026-01-06.at.14.51.47.mov

@Julesssss
Copy link
Contributor

The edit flow doesn't work. When update the start and end point, we should save it locally and only call update API in the detail confirmation page

@DylanDylann I don't think so. For the expense edits we have a unique API for each of the fields. For example, when updating expense description:

Screenshot 2026-01-06 at 15 21 49

However, we should not show this modal. I applied a fix so we only show discard when CREATING the odometer expense. It's less dangerous when editing the distance post-creation
Screenshot 2026-01-06 at 15 22 44

@Julesssss
Copy link
Contributor

When creating it offline, the merchant field is shown instead of the distance field, and there’s an error with the tax field (it’s inconsistent with the BE response)

I'm going to look into this as a separate issue here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants