From 3f8963273be0c737da78ea47e4bcf7adabd9ca5f Mon Sep 17 00:00:00 2001
From: Robert Dyer <rdyer@unl.edu>
Date: Tue, 3 Sep 2024 13:23:15 -0500
Subject: [PATCH] Translation: desktop-client/components/schedules (#3313)

* Translation: desktop-client/components/schedules

* add release note

* better handling of plural

* clean plural use

* add review suggestions

* more review suggestions

* change to t() syntax

* use basic list format

* eslint no longer needs disabled

* Fix interpolation of payees list.

Co-authored-by: Julian Dominguez-Schatz <jfdoming@uwaterloo.ca>

* fix linter

* fix typecheck

* tighten the types

* move to hook

* memoize the hook

---------

Co-authored-by: Julian Dominguez-Schatz <jfdoming@uwaterloo.ca>
---
 .../schedules/DiscoverSchedules.tsx           | 39 ++++++---
 .../schedules/PostsOfflineNotification.jsx    | 77 +++++++++--------
 .../components/schedules/ScheduleDetails.jsx  | 84 +++++++++++--------
 .../src/components/schedules/ScheduleLink.tsx | 18 ++--
 .../components/schedules/SchedulesTable.tsx   | 67 ++++++++++-----
 .../src/components/schedules/index.tsx        | 13 ++-
 .../desktop-client/src/hooks/useFormatList.ts | 32 +++++++
 upcoming-release-notes/3313.md                |  6 ++
 8 files changed, 223 insertions(+), 113 deletions(-)
 create mode 100644 packages/desktop-client/src/hooks/useFormatList.ts
 create mode 100644 upcoming-release-notes/3313.md

diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
index 31d7fc10a..dd7aee001 100644
--- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
+++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
@@ -1,5 +1,6 @@
 // @ts-strict-ignore
 import React, { useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 
 import { runQuery } from 'loot-core/src/client/query-helpers';
 import { send } from 'loot-core/src/platform/client/fetch';
@@ -35,6 +36,8 @@ function DiscoverSchedulesTable({
   schedules: DiscoverScheduleEntity[];
   loading: boolean;
 }) {
+  const { t } = useTranslation();
+
   const selectedItems = useSelectedItems();
   const dispatchSelected = useSelectedDispatch();
   const dateFormat = useDateFormat() || 'MM/dd/yyyy';
@@ -107,13 +110,17 @@ function DiscoverSchedulesTable({
             dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
           }
         />
-        <Field width="flex">Payee</Field>
-        <Field width="flex">Account</Field>
+        <Field width="flex">
+          <Trans>Payee</Trans>
+        </Field>
+        <Field width="flex">
+          <Trans>Account</Trans>
+        </Field>
         <Field width="auto" style={{ flex: 1.5 }}>
-          When
+          <Trans>When</Trans>
         </Field>
         <Field width={100} style={{ textAlign: 'right' }}>
-          Amount
+          <Trans>Amount</Trans>
         </Field>
       </TableHeader>
       <Table
@@ -126,13 +133,15 @@ function DiscoverSchedulesTable({
         loading={loading}
         isSelected={id => selectedItems.has(String(id))}
         renderItem={renderItem}
-        renderEmpty="No schedules found"
+        renderEmpty={t('No schedules found')}
       />
     </View>
   );
 }
 
 export function DiscoverSchedules() {
+  const { t } = useTranslation();
+
   const { data, isLoading } = useSendPlatformRequest('schedule/discover');
 
   const schedules = data || [];
@@ -185,18 +194,22 @@ export function DiscoverSchedules() {
       {({ state: { close } }) => (
         <>
           <ModalHeader
-            title="Found Schedules"
+            title={t('Found Schedules')}
             rightContent={<ModalCloseButton onClick={close} />}
           />
           <Paragraph>
-            We found some possible schedules in your current transactions.
-            Select the ones you want to create.
+            <Trans>
+              We found some possible schedules in your current transactions.
+              Select the ones you want to create.
+            </Trans>
           </Paragraph>
           <Paragraph>
-            If you expected a schedule here and don’t see it, it might be
-            because the payees of the transactions don’t match. Make sure you
-            rename payees on all transactions for a schedule to be the same
-            payee.
+            <Trans>
+              If you expected a schedule here and don’t see it, it might be
+              because the payees of the transactions don’t match. Make sure you
+              rename payees on all transactions for a schedule to be the same
+              payee.
+            </Trans>
           </Paragraph>
 
           <SelectedProvider instance={selectedInst}>
@@ -221,7 +234,7 @@ export function DiscoverSchedules() {
                 close();
               }}
             >
-              Create schedules
+              <Trans>Create schedules</Trans>
             </ButtonWithLoading>
           </Stack>
         </>
diff --git a/packages/desktop-client/src/components/schedules/PostsOfflineNotification.jsx b/packages/desktop-client/src/components/schedules/PostsOfflineNotification.jsx
index 29ad0f5da..a1ba84dd3 100644
--- a/packages/desktop-client/src/components/schedules/PostsOfflineNotification.jsx
+++ b/packages/desktop-client/src/components/schedules/PostsOfflineNotification.jsx
@@ -1,10 +1,12 @@
 import React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 import { useDispatch } from 'react-redux';
 import { useLocation } from 'react-router-dom';
 
 import { popModal } from 'loot-core/client/actions';
 import { send } from 'loot-core/src/platform/client/fetch';
 
+import { useFormatList } from '../../hooks/useFormatList';
 import { theme } from '../../style';
 import { Button } from '../common/Button2';
 import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
@@ -14,63 +16,66 @@ import { Text } from '../common/Text';
 import { DisplayId } from '../util/DisplayId';
 
 export function PostsOfflineNotification() {
+  const { t } = useTranslation();
+
   const location = useLocation();
   const dispatch = useDispatch();
 
   const payees = (location.state && location.state.payees) || [];
-  const plural = payees.length > 1;
 
   async function onPost() {
     await send('schedule/force-run-service');
     dispatch(popModal());
   }
 
+  const payeesList = payees.map(id => (
+    <Text key={id} style={{ color: theme.pageTextPositive }}>
+      <DisplayId id={id} type="payees" />
+    </Text>
+  ));
+  const payeeNamesList = useFormatList(payeesList, t.language);
+
   return (
     <Modal name="schedule-posts-offline-notification">
       {({ state: { close } }) => (
         <>
           <ModalHeader
-            title="Post transactions?"
+            title={t('Post transactions?')}
             rightContent={<ModalCloseButton onClick={close} />}
           />
           <Paragraph>
-            {payees.length > 0 ? (
-              <Text>
-                The {plural ? 'payees ' : 'payee '}
-                {payees.map((id, idx) => (
-                  <Text key={id}>
-                    <Text style={{ color: theme.pageTextPositive }}>
-                      <DisplayId id={id} type="payees" />
-                    </Text>
-                    {idx === payees.length - 1
-                      ? ' '
-                      : idx === payees.length - 2
-                        ? ', and '
-                        : ', '}
-                  </Text>
-                ))}
-              </Text>
-            ) : (
-              <Text>There {plural ? 'are payees ' : 'is a payee '} that </Text>
-            )}
-
             <Text>
-              {plural ? 'have ' : 'has '} schedules that are due today. Usually
-              we automatically post transactions for these, but you are offline
-              or syncing failed. In order to avoid duplicate transactions, we
-              let you choose whether or not to create transactions for these
-              schedules.
+              {payees.length > 0 ? (
+                <Trans count={payees.length}>
+                  The payees <span>{payeeNamesList}</span> have schedules that
+                  are due today.
+                </Trans>
+              ) : (
+                t('There are payees that have schedules that are due today.', {
+                  count: payees.length,
+                })
+              )}{' '}
+              <Trans>
+                Usually we automatically post transactions for these, but you
+                are offline or syncing failed. In order to avoid duplicate
+                transactions, we let you choose whether or not to create
+                transactions for these schedules.
+              </Trans>
             </Text>
           </Paragraph>
           <Paragraph>
-            Be aware that other devices may have already created these
-            transactions. If you have multiple devices, make sure you only do
-            this on one device or you will have duplicate transactions.
+            <Trans>
+              Be aware that other devices may have already created these
+              transactions. If you have multiple devices, make sure you only do
+              this on one device or you will have duplicate transactions.
+            </Trans>
           </Paragraph>
           <Paragraph>
-            You can always manually post a transaction later for a due schedule
-            by selecting the schedule and clicking “Post transaction” in the
-            action menu.
+            <Trans>
+              You can always manually post a transaction later for a due
+              schedule by selecting the schedule and clicking “Post transaction”
+              in the action menu.
+            </Trans>
           </Paragraph>
           <Stack
             direction="row"
@@ -78,7 +83,9 @@ export function PostsOfflineNotification() {
             style={{ marginTop: 20 }}
             spacing={2}
           >
-            <Button onPress={close}>Decide later</Button>
+            <Button onPress={close}>
+              <Trans>Decide later</Trans>
+            </Button>
             <Button
               variant="primary"
               autoFocus
@@ -87,7 +94,7 @@ export function PostsOfflineNotification() {
                 close();
               }}
             >
-              Post transactions
+              <Trans>Post transactions</Trans>
             </Button>
           </Stack>
         </>
diff --git a/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx b/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx
index 67900bf27..f9419ffdf 100644
--- a/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx
+++ b/packages/desktop-client/src/components/schedules/ScheduleDetails.jsx
@@ -1,4 +1,5 @@
 import React, { useEffect, useReducer } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 import { useDispatch } from 'react-redux';
 
 import { t } from 'i18next';
@@ -49,11 +50,11 @@ function updateScheduleConditions(schedule, fields) {
 
   // Validate
   if (fields.date == null) {
-    return { error: 'Date is required' };
+    return { error: t('Date is required') };
   }
 
   if (fields.amount == null) {
-    return { error: 'A valid amount is required' };
+    return { error: t('A valid amount is required') };
   }
 
   return {
@@ -73,6 +74,8 @@ function updateScheduleConditions(schedule, fields) {
 }
 
 export function ScheduleDetails({ id, transaction }) {
+  const { t } = useTranslation();
+
   const adding = id == null;
   const fromTrans = transaction != null;
   const payees = getPayeesById(usePayees());
@@ -365,7 +368,7 @@ export function ScheduleDetails({ id, transaction }) {
       if (sameName.length > 0 && sameName[0].id !== state.schedule.id) {
         dispatch({
           type: 'form-error',
-          error: 'There is already a schedule with this name',
+          error: t('There is already a schedule with this name'),
         });
         return;
       }
@@ -396,8 +399,9 @@ export function ScheduleDetails({ id, transaction }) {
     if (res.error) {
       dispatch({
         type: 'form-error',
-        error:
+        error: t(
           'An error occurred while saving. Please visit https://actualbudget.org/contact/ for support.',
+        ),
       });
       return;
     }
@@ -457,12 +461,16 @@ export function ScheduleDetails({ id, transaction }) {
       {({ state: { close } }) => (
         <>
           <ModalHeader
-            title={payee ? `Schedule: ${payee.name}` : 'Schedule'}
+            title={
+              payee
+                ? t(`Schedule: {{name}}`, { name: payee.name })
+                : t('Schedule')
+            }
             rightContent={<ModalCloseButton onClick={close} />}
           />
           <Stack direction="row" style={{ marginTop: 10 }}>
             <FormField style={{ flex: 1 }}>
-              <FormLabel title="Schedule Name" htmlFor="name-field" />
+              <FormLabel title={t('Schedule Name')} htmlFor="name-field" />
               <InitialFocus>
                 <GenericInput
                   field="string"
@@ -478,11 +486,15 @@ export function ScheduleDetails({ id, transaction }) {
           </Stack>
           <Stack direction="row" style={{ marginTop: 20 }}>
             <FormField style={{ flex: 1 }}>
-              <FormLabel title="Payee" id="payee-label" htmlFor="payee-field" />
+              <FormLabel
+                title={t('Payee')}
+                id="payee-label"
+                htmlFor="payee-field"
+              />
               <PayeeAutocomplete
                 value={state.fields.payee}
                 labelProps={{ id: 'payee-label' }}
-                inputProps={{ id: 'payee-field', placeholder: '(none)' }}
+                inputProps={{ id: 'payee-field', placeholder: t('(none)') }}
                 onSelect={id =>
                   dispatch({ type: 'set-field', field: 'payee', value: id })
                 }
@@ -491,7 +503,7 @@ export function ScheduleDetails({ id, transaction }) {
 
             <FormField style={{ flex: 1 }}>
               <FormLabel
-                title="Account"
+                title={t('Account')}
                 id="account-label"
                 htmlFor="account-field"
               />
@@ -499,7 +511,7 @@ export function ScheduleDetails({ id, transaction }) {
                 includeClosedAccounts={false}
                 value={state.fields.account}
                 labelProps={{ id: 'account-label' }}
-                inputProps={{ id: 'account-field', placeholder: '(none)' }}
+                inputProps={{ id: 'account-field', placeholder: t('(none)') }}
                 onSelect={id =>
                   dispatch({ type: 'set-field', field: 'account', value: id })
                 }
@@ -509,7 +521,7 @@ export function ScheduleDetails({ id, transaction }) {
             <FormField style={{ flex: 1 }}>
               <Stack direction="row" align="center" style={{ marginBottom: 3 }}>
                 <FormLabel
-                  title="Amount"
+                  title={t('Amount')}
                   htmlFor="amount-field"
                   style={{ margin: 0, flex: 1 }}
                 />
@@ -519,11 +531,11 @@ export function ScheduleDetails({ id, transaction }) {
                   formatOp={op => {
                     switch (op) {
                       case 'is':
-                        return 'is exactly';
+                        return t('is exactly');
                       case 'isapprox':
-                        return 'is approximately';
+                        return t('is approximately');
                       case 'isbetween':
-                        return 'is between';
+                        return t('is between');
                       default:
                         throw new Error('Invalid op for select: ' + op);
                     }
@@ -570,7 +582,7 @@ export function ScheduleDetails({ id, transaction }) {
           </Stack>
 
           <View style={{ marginTop: 20 }}>
-            <FormLabel title="Date" />
+            <FormLabel title={t('Date')} />
           </View>
 
           <Stack direction="row" align="flex-start" justify="space-between">
@@ -595,7 +607,7 @@ export function ScheduleDetails({ id, transaction }) {
               {state.upcomingDates && (
                 <View style={{ fontSize: 13, marginTop: 20 }}>
                   <Text style={{ color: theme.pageTextLight, fontWeight: 600 }}>
-                    Upcoming dates
+                    <Trans>Upcoming dates</Trans>
                   </Text>
                   <Stack
                     direction="column"
@@ -628,7 +640,7 @@ export function ScheduleDetails({ id, transaction }) {
                 }}
               />
               <label htmlFor="form_repeats" style={{ userSelect: 'none' }}>
-                Repeats
+                <Trans>Repeats</Trans>
               </label>
             </View>
 
@@ -657,7 +669,7 @@ export function ScheduleDetails({ id, transaction }) {
                   htmlFor="form_posts_transaction"
                   style={{ userSelect: 'none' }}
                 >
-                  Automatically add transaction
+                  <Trans>Automatically add transaction</Trans>
                 </label>
               </View>
 
@@ -671,8 +683,10 @@ export function ScheduleDetails({ id, transaction }) {
                   lineHeight: '1.4em',
                 }}
               >
-                If checked, the schedule will automatically create transactions
-                for you in the specified account
+                <Trans>
+                  If checked, the schedule will automatically create
+                  transactions for you in the specified account
+                </Trans>
               </Text>
 
               {!adding && state.schedule.rule && (
@@ -686,11 +700,13 @@ export function ScheduleDetails({ id, transaction }) {
                         width: 350,
                       }}
                     >
-                      This schedule has custom conditions and actions
+                      <Trans>
+                        This schedule has custom conditions and actions
+                      </Trans>
                     </Text>
                   )}
                   <Button onPress={() => onEditRule()} isDisabled={adding}>
-                    Edit as rule
+                    <Trans>Edit as rule</Trans>
                   </Button>
                 </Stack>
               )}
@@ -702,11 +718,11 @@ export function ScheduleDetails({ id, transaction }) {
               {adding ? (
                 <View style={{ flexDirection: 'row', padding: '5px 0' }}>
                   <Text style={{ color: theme.pageTextLight }}>
-                    These transactions match this schedule:
+                    <Trans>These transactions match this schedule:</Trans>
                   </Text>
                   <View style={{ flex: 1 }} />
                   <Text style={{ color: theme.pageTextLight }}>
-                    Select transactions to link on save
+                    <Trans>Select transactions to link on save</Trans>
                   </Text>
                 </View>
               ) : (
@@ -723,7 +739,7 @@ export function ScheduleDetails({ id, transaction }) {
                     }}
                     onPress={() => onSwitchTransactions('linked')}
                   >
-                    Linked transactions
+                    <Trans>Linked transactions</Trans>
                   </Button>{' '}
                   <Button
                     variant="bare"
@@ -736,7 +752,7 @@ export function ScheduleDetails({ id, transaction }) {
                     }}
                     onPress={() => onSwitchTransactions('matched')}
                   >
-                    Find matching transactions
+                    <Trans>Find matching transactions</Trans>
                   </Button>
                   <View style={{ flex: 1 }} />
                   <SelectedItemsButton
@@ -744,8 +760,8 @@ export function ScheduleDetails({ id, transaction }) {
                     name={count => t('{{count}} transactions', { count })}
                     items={
                       state.transactionsMode === 'linked'
-                        ? [{ name: 'unlink', text: 'Unlink from schedule' }]
-                        : [{ name: 'link', text: 'Link to schedule' }]
+                        ? [{ name: 'unlink', text: t('Unlink from schedule') }]
+                        : [{ name: 'link', text: t('Link to schedule') }]
                     }
                     onSelect={(name, ids) => {
                       switch (name) {
@@ -792,7 +808,7 @@ export function ScheduleDetails({ id, transaction }) {
               <Text style={{ color: theme.errorText }}>{state.error}</Text>
             )}
             <Button style={{ marginRight: 10 }} onPress={close}>
-              Cancel
+              <Trans>Cancel</Trans>
             </Button>
             <Button
               variant="primary"
@@ -800,7 +816,7 @@ export function ScheduleDetails({ id, transaction }) {
                 onSave(close);
               }}
             >
-              {adding ? 'Add' : 'Save'}
+              {adding ? t('Add') : t('Save')}
             </Button>
           </Stack>
         </>
@@ -810,6 +826,8 @@ export function ScheduleDetails({ id, transaction }) {
 }
 
 function NoTransactionsMessage(props) {
+  const { t } = useTranslation();
+
   return (
     <View
       style={{
@@ -820,12 +838,12 @@ function NoTransactionsMessage(props) {
     >
       {props.error ? (
         <Text style={{ color: theme.errorText }}>
-          Could not search: {props.error}
+          <Trans>Could not search: {props.error}</Trans>
         </Text>
       ) : props.transactionsMode === 'matched' ? (
-        'No matching transactions'
+        t('No matching transactions')
       ) : (
-        'No linked transactions'
+        t('No linked transactions')
       )}
     </View>
   );
diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
index 15b3409a7..7b3a3e406 100644
--- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
+++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
@@ -1,5 +1,6 @@
 // @ts-strict-ignore
 import React, { useCallback, useRef, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 import { useDispatch } from 'react-redux';
 
 import { pushModal } from 'loot-core/client/actions';
@@ -32,6 +33,8 @@ export function ScheduleLink({
   accountName?: string;
   onScheduleLinked?: (schedule: ScheduleEntity) => void;
 }) {
+  const { t } = useTranslation();
+
   const dispatch = useDispatch();
   const [filter, setFilter] = useState(accountName || '');
 
@@ -76,7 +79,7 @@ export function ScheduleLink({
       {({ state: { close } }) => (
         <>
           <ModalHeader
-            title="Link Schedule"
+            title={t('Link Schedule')}
             rightContent={<ModalCloseButton onClick={close} />}
           />
           <View
@@ -88,18 +91,17 @@ export function ScheduleLink({
             }}
           >
             <Text>
-              Choose the schedule{' '}
-              {ids?.length > 1
-                ? `these ${ids.length} transactions belong`
-                : `this transaction belongs`}{' '}
-              to:
+              {t(
+                'Choose the schedule these {{ count }} transactions belong to:',
+                { count: ids?.length ?? 0 },
+              )}
             </Text>
             <InitialFocus>
               <Search
                 inputRef={searchInput}
                 isInModal
                 width={300}
-                placeholder="Filter schedules…"
+                placeholder={t('Filter schedules…')}
                 value={filter}
                 onChange={setFilter}
               />
@@ -114,7 +116,7 @@ export function ScheduleLink({
                 }}
               >
                 <SvgAdd style={{ width: '20', padding: '3' }} />
-                Create New
+                <Trans>Create New</Trans>
               </Button>
             )}
           </View>
diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
index 66bc8fa82..25d97f9e3 100644
--- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
+++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
@@ -1,5 +1,6 @@
 // @ts-strict-ignore
 import React, { useRef, useState, useMemo, type CSSProperties } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 
 import {
   type ScheduleStatusType,
@@ -61,6 +62,8 @@ function OverflowMenu({
   status: ScheduleStatusType;
   onAction: SchedulesTableProps['onAction'];
 }) {
+  const { t } = useTranslation();
+
   const triggerRef = useRef(null);
   const [open, setOpen] = useState(false);
 
@@ -69,28 +72,28 @@ function OverflowMenu({
 
     menuItems.push({
       name: 'post-transaction',
-      text: 'Post transaction',
+      text: t('Post transaction'),
     });
 
     if (status === 'completed') {
       menuItems.push({
         name: 'restart',
-        text: 'Restart',
+        text: t('Restart'),
       });
     } else {
       menuItems.push(
         {
           name: 'skip',
-          text: 'Skip next date',
+          text: t('Skip next date'),
         },
         {
           name: 'complete',
-          text: 'Complete',
+          text: t('Complete'),
         },
       );
     }
 
-    menuItems.push({ name: 'delete', text: 'Delete' });
+    menuItems.push({ name: 'delete', text: t('Delete') });
 
     return menuItems;
   };
@@ -100,7 +103,7 @@ function OverflowMenu({
       <Button
         ref={triggerRef}
         variant="bare"
-        aria-label="Menu"
+        aria-label={t('Menu')}
         onPress={() => {
           setOpen(true);
         }}
@@ -136,8 +139,10 @@ export function ScheduleAmountCell({
   amount: ScheduleEntity['_amount'];
   op: ScheduleEntity['_amountOp'];
 }) {
+  const { t } = useTranslation();
+
   const num = getScheduledAmount(amount);
-  const str = integerToCurrency(Math.abs(num || 0));
+  const currencyAmount = integerToCurrency(Math.abs(num || 0));
   const isApprox = op === 'isapprox' || op === 'isbetween';
 
   return (
@@ -160,7 +165,11 @@ export function ScheduleAmountCell({
             lineHeight: '1em',
             marginRight: 10,
           }}
-          title={(isApprox ? 'Approximately ' : '') + str}
+          title={
+            isApprox
+              ? t('Approximately {{currencyAmount}}', { currencyAmount })
+              : currencyAmount
+          }
         >
           ~
         </View>
@@ -173,9 +182,15 @@ export function ScheduleAmountCell({
           overflow: 'hidden',
           textOverflow: 'ellipsis',
         }}
-        title={(isApprox ? 'Approximately ' : '') + str}
+        title={
+          isApprox
+            ? t('Approximately {{currencyAmount}}', { currencyAmount })
+            : currencyAmount
+        }
       >
-        <PrivacyFilter>{num > 0 ? `+${str}` : `${str}`}</PrivacyFilter>
+        <PrivacyFilter>
+          {num > 0 ? `+${currencyAmount}` : `${currencyAmount}`}
+        </PrivacyFilter>
       </Text>
     </Cell>
   );
@@ -192,6 +207,8 @@ export function SchedulesTable({
   onAction,
   tableStyle,
 }: SchedulesTableProps) {
+  const { t } = useTranslation();
+
   const dateFormat = useDateFormat() || 'MM/dd/yyyy';
   const [showCompleted, setShowCompleted] = useState(false);
 
@@ -272,7 +289,7 @@ export function SchedulesTable({
             }
             title={schedule.name ? schedule.name : ''}
           >
-            {schedule.name ? schedule.name : 'None'}
+            {schedule.name ? schedule.name : t('None')}
           </Text>
         </Field>
         <Field width="flex" name="payee">
@@ -331,7 +348,7 @@ export function SchedulesTable({
               color: theme.tableText,
             }}
           >
-            Show completed schedules
+            <Trans>Show completed schedules</Trans>
           </Field>
         </Row>
       );
@@ -342,17 +359,27 @@ export function SchedulesTable({
   return (
     <View style={{ flex: 1, ...tableStyle }}>
       <TableHeader height={ROW_HEIGHT} inset={15}>
-        <Field width="flex">Name</Field>
-        <Field width="flex">Payee</Field>
-        <Field width="flex">Account</Field>
-        <Field width={110}>Next date</Field>
-        <Field width={120}>Status</Field>
+        <Field width="flex">
+          <Trans>Name</Trans>
+        </Field>
+        <Field width="flex">
+          <Trans>Payee</Trans>
+        </Field>
+        <Field width="flex">
+          <Trans>Account</Trans>
+        </Field>
+        <Field width={110}>
+          <Trans>Next date</Trans>
+        </Field>
+        <Field width={120}>
+          <Trans>Status</Trans>
+        </Field>
         <Field width={100} style={{ textAlign: 'right' }}>
-          Amount
+          <Trans>Amount</Trans>
         </Field>
         {!minimal && (
           <Field width={80} style={{ textAlign: 'center' }}>
-            Recurring
+            <Trans>Recurring</Trans>
           </Field>
         )}
         {!minimal && <Field width={40} />}
@@ -363,7 +390,7 @@ export function SchedulesTable({
         style={{ flex: 1, backgroundColor: 'transparent', ...style }}
         items={items as ScheduleEntity[]}
         renderItem={renderItem}
-        renderEmpty={filter ? 'No matching schedules' : 'No schedules'}
+        renderEmpty={filter ? t('No matching schedules') : t('No schedules')}
       />
     </View>
   );
diff --git a/packages/desktop-client/src/components/schedules/index.tsx b/packages/desktop-client/src/components/schedules/index.tsx
index daca57a39..ffa41344e 100644
--- a/packages/desktop-client/src/components/schedules/index.tsx
+++ b/packages/desktop-client/src/components/schedules/index.tsx
@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
 
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
@@ -14,6 +15,8 @@ import { Page } from '../Page';
 import { SchedulesTable, type ScheduleItemAction } from './SchedulesTable';
 
 export function Schedules() {
+  const { t } = useTranslation();
+
   const { pushModal } = useActions();
   const [filter, setFilter] = useState('');
 
@@ -64,7 +67,7 @@ export function Schedules() {
   }
 
   return (
-    <Page header="Schedules">
+    <Page header={t('Schedules')}>
       <View
         style={{
           flexDirection: 'row',
@@ -80,7 +83,7 @@ export function Schedules() {
           }}
         >
           <Search
-            placeholder="Filter schedules…"
+            placeholder={t('Filter schedules…')}
             value={filter}
             onChange={setFilter}
           />
@@ -105,9 +108,11 @@ export function Schedules() {
           flexShrink: 0,
         }}
       >
-        <Button onPress={onDiscover}>Find schedules</Button>
+        <Button onPress={onDiscover}>
+          <Trans>Find schedules</Trans>
+        </Button>
         <Button variant="primary" onPress={onAdd}>
-          Add new schedule
+          <Trans>Add new schedule</Trans>
         </Button>
       </View>
     </Page>
diff --git a/packages/desktop-client/src/hooks/useFormatList.ts b/packages/desktop-client/src/hooks/useFormatList.ts
new file mode 100644
index 000000000..251df4fb1
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useFormatList.ts
@@ -0,0 +1,32 @@
+import { useMemo, type ReactNode } from 'react';
+
+const interleaveArrays = (...arrays: ReactNode[][]) =>
+  Array.from(
+    {
+      length: Math.max(...arrays.map(array => array.length)),
+    },
+    (_, i) => arrays.map(array => array[i]),
+  ).flat();
+
+export function useFormatList(values: ReactNode[], lng: string, opt = {}) {
+  const formatter = useMemo(
+    () =>
+      new Intl.ListFormat(lng, {
+        style: 'long',
+        type: 'conjunction',
+        ...opt,
+      }),
+    [lng, opt],
+  );
+
+  const parts = useMemo(() => {
+    const placeholders = Array.from(
+      { length: values.length },
+      (_, i) => `<${i}>`,
+    );
+    const formatted = formatter.format(placeholders);
+    return formatted.split(/<\d+>/g);
+  }, [values.length, formatter]);
+
+  return interleaveArrays(parts, values);
+}
diff --git a/upcoming-release-notes/3313.md b/upcoming-release-notes/3313.md
new file mode 100644
index 000000000..555b2ab24
--- /dev/null
+++ b/upcoming-release-notes/3313.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [psybers]
+---
+
+Support translations in desktop-client/components/schedules.
-- 
GitLab