From 97b1b6f81570bc0eb9a2631e4eb5be812aabe9cd Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Sat, 18 Mar 2023 09:41:38 -0400
Subject: [PATCH] Improve handling of large currency amounts (#725)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add a “hide decimal places” setting to visually hide the `.xx` from
currency values globally
- When hiding the fractional digits, slightly decrease character spacing
to allow more digits to show up

Ref: #327

New settings:
<img width="566" alt="Screenshot_2023-03-17 14 19 46"
src="https://user-images.githubusercontent.com/25517624/225986815-b884b93d-02f9-48b3-a73d-d27f90b678cf.png">


Before/after:
<img width="149" alt="Screenshot_2023-02-27 21 47 07"
src="https://user-images.githubusercontent.com/25517624/222916856-21ab4f03-56c6-4b24-8fc1-ac4b883138b7.png"><img
width="131" alt="Screenshot_2023-02-27 22 02 01"
src="https://user-images.githubusercontent.com/25517624/222916859-cf882ca3-6087-4994-818e-239c3374e412.png">
---
 .../src/components/accounts/Account.js        |   3 +
 .../src/components/accounts/MobileAccount.js  |   3 +-
 .../src/components/accounts/MobileAccounts.js |   3 +-
 .../components/accounts/TransactionList.js    |   2 +
 .../components/accounts/TransactionsTable.js  |  19 ++-
 .../src/components/budget/MobileBudget.js     |   3 +-
 .../src/components/settings/Format.js         | 121 ++++++++++++------
 .../loot-core/src/client/reducers/prefs.js    |  15 ++-
 packages/loot-core/src/shared/util.js         |  14 +-
 packages/loot-core/src/shared/util.test.js    |  27 ++--
 packages/loot-design/src/components/common.js |   3 +-
 packages/loot-design/src/style.js             |   4 +-
 packages/loot-design/src/tokens.js            |   1 +
 upcoming-release-notes/725.md                 |   6 +
 14 files changed, 157 insertions(+), 67 deletions(-)
 create mode 100644 upcoming-release-notes/725.md

diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js
index e3db75657..32bccd20c 100644
--- a/packages/desktop-client/src/components/accounts/Account.js
+++ b/packages/desktop-client/src/components/accounts/Account.js
@@ -1728,6 +1728,7 @@ class AccountInternal extends React.PureComponent {
       payees,
       syncEnabled,
       dateFormat,
+      hideFraction,
       addNotification,
       accountsSyncing,
       replaceModal,
@@ -1856,6 +1857,7 @@ class AccountInternal extends React.PureComponent {
                       this.state.search !== '' || this.state.filters.length > 0
                     }
                     dateFormat={dateFormat}
+                    hideFraction={hideFraction}
                     addNotification={addNotification}
                     renderEmpty={() =>
                       showEmptyMessage ? (
@@ -1920,6 +1922,7 @@ export default function Account(props) {
     categoryGroups: state.queries.categories.grouped,
     syncEnabled: state.prefs.local['flags.syncAccount'],
     dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
+    hideFraction: state.prefs.local.hideFraction || false,
     expandSplits: props.match && state.prefs.local['expand-splits'],
     showBalances:
       props.match &&
diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js
index 772c4db18..cab2574d4 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccount.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccount.js
@@ -231,6 +231,7 @@ function Account(props) {
 
   let balance = queries.accountBalance(account);
   let numberFormat = state.prefs.numberFormat || 'comma-dot';
+  let hideFraction = state.prefs.hideFraction || false;
 
   return (
     <SyncRefresh onSync={onRefresh}>
@@ -246,7 +247,7 @@ function Account(props) {
                   // format changes
                   {...state}
                   {...actionCreators}
-                  key={numberFormat}
+                  key={numberFormat + hideFraction}
                   account={account}
                   accounts={props.accounts}
                   categories={state.categories}
diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.js b/packages/desktop-client/src/components/accounts/MobileAccounts.js
index f655274d1..b2c80aeb8 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccounts.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccounts.js
@@ -306,13 +306,14 @@ function Accounts(props) {
 
   let { accounts, categories, newTransactions, updatedAccounts, prefs } = props;
   let numberFormat = prefs.numberFormat || 'comma-dot';
+  let hideFraction = prefs.hideFraction || false;
 
   return (
     <View style={{ flex: 1 }}>
       <AccountList
         // This key forces the whole table rerender when the number
         // format changes
-        key={numberFormat}
+        key={numberFormat + hideFraction}
         accounts={accounts.filter(account => !account.closed)}
         categories={categories}
         transactions={transactions || []}
diff --git a/packages/desktop-client/src/components/accounts/TransactionList.js b/packages/desktop-client/src/components/accounts/TransactionList.js
index e3050fa2a..96c1e3096 100644
--- a/packages/desktop-client/src/components/accounts/TransactionList.js
+++ b/packages/desktop-client/src/components/accounts/TransactionList.js
@@ -71,6 +71,7 @@ export default function TransactionList({
   isMatched,
   isFiltered,
   dateFormat,
+  hideFraction,
   addNotification,
   renderEmpty,
   onChange,
@@ -170,6 +171,7 @@ export default function TransactionList({
       isMatched={isMatched}
       isFiltered={isFiltered}
       dateFormat={dateFormat}
+      hideFraction={hideFraction}
       addNotification={addNotification}
       headerContent={headerContent}
       renderEmpty={renderEmpty}
diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.js b/packages/desktop-client/src/components/accounts/TransactionsTable.js
index 61fa795fc..3c93f8aff 100644
--- a/packages/desktop-client/src/components/accounts/TransactionsTable.js
+++ b/packages/desktop-client/src/components/accounts/TransactionsTable.js
@@ -264,7 +264,7 @@ export const TransactionHeader = React.memo(
         {showCategory && <Cell value="Category" width="flex" />}
         <Cell value="Payment" width={80} textAlign="right" />
         <Cell value="Deposit" width={80} textAlign="right" />
-        {showBalance && <Cell value="Balance" width={85} textAlign="right" />}
+        {showBalance && <Cell value="Balance" width={88} textAlign="right" />}
         {showCleared && <Field width={21} truncate={false} />}
         <Cell value="" width={15 + styles.scrollbarWidth} />
       </Row>
@@ -517,6 +517,7 @@ export const Transaction = React.memo(function Transaction(props) {
     accounts,
     balance,
     dateFormat = 'MM/dd/yyyy',
+    hideFraction,
     onSave,
     onEdit,
     onHover,
@@ -621,6 +622,7 @@ export const Transaction = React.memo(function Transaction(props) {
 
   let valueStyle = added ? { fontWeight: 600 } : null;
   let backgroundFocus = hovered || focusedField === 'select';
+  let amountStyle = hideFraction ? { letterSpacing: -0.5 } : null;
 
   return (
     <Row
@@ -992,7 +994,7 @@ export const Transaction = React.memo(function Transaction(props) {
         textAlign="right"
         title={debit}
         onExpose={!isPreview && (name => onEdit(id, name))}
-        style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
+        style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
         inputProps={{
           value: debit,
           onUpdate: onUpdate.bind(null, 'debit'),
@@ -1010,7 +1012,7 @@ export const Transaction = React.memo(function Transaction(props) {
         textAlign="right"
         title={credit}
         onExpose={!isPreview && (name => onEdit(id, name))}
-        style={[isParent && { fontStyle: 'italic' }, styles.tnum]}
+        style={[isParent && { fontStyle: 'italic' }, styles.tnum, amountStyle]}
         inputProps={{
           value: credit,
           onUpdate: onUpdate.bind(null, 'credit'),
@@ -1026,8 +1028,8 @@ export const Transaction = React.memo(function Transaction(props) {
               : integerToCurrency(balance)
           }
           valueStyle={{ color: balance < 0 ? colors.r4 : colors.g4 }}
-          style={styles.tnum}
-          width={85}
+          style={[styles.tnum, amountStyle]}
+          width={88}
           textAlign="right"
         />
       )}
@@ -1124,6 +1126,7 @@ function NewTransaction({
   showBalance,
   showCleared,
   dateFormat,
+  hideFraction,
   onHover,
   onClose,
   onSplit,
@@ -1169,6 +1172,7 @@ function NewTransaction({
           categoryGroups={categoryGroups}
           payees={payees}
           dateFormat={dateFormat}
+          hideFraction={hideFraction}
           expanded={true}
           onHover={onHover}
           onEdit={onEdit}
@@ -1249,6 +1253,7 @@ function TransactionTableInner({
       showAccount,
       showCategory,
       balances,
+      hideFraction,
       isNew,
       isMatched,
       isExpanded,
@@ -1313,7 +1318,8 @@ function TransactionTableInner({
               : new Set()
           }
           dateFormat={dateFormat}
-          onHover={onHover}
+          hideFraction={hideFraction}
+          onHover={props.onHover}
           onEdit={tableNavigator.onEdit}
           onSave={props.onSave}
           onDelete={props.onDelete}
@@ -1359,6 +1365,7 @@ function TransactionTableInner({
               showBalance={!!props.balances}
               showCleared={props.showCleared}
               dateFormat={dateFormat}
+              hideFraction={props.hideFraction}
               onClose={props.onCloseAddTransaction}
               onAdd={props.onAddTemporary}
               onAddSplit={props.onAddSplit}
diff --git a/packages/desktop-client/src/components/budget/MobileBudget.js b/packages/desktop-client/src/components/budget/MobileBudget.js
index f2f4799ac..02bbaeb89 100644
--- a/packages/desktop-client/src/components/budget/MobileBudget.js
+++ b/packages/desktop-client/src/components/budget/MobileBudget.js
@@ -241,6 +241,7 @@ class Budget extends React.Component {
       applyBudgetAction,
     } = this.props;
     let numberFormat = prefs.numberFormat || 'comma-dot';
+    let hideFraction = prefs.hideFraction || false;
 
     if (!categoryGroups || !initialized) {
       return (
@@ -264,7 +265,7 @@ class Budget extends React.Component {
           <BudgetTable
             // This key forces the whole table rerender when the number
             // format changes
-            key={numberFormat}
+            key={numberFormat + hideFraction}
             categories={categories}
             categoryGroups={categoryGroups}
             type={budgetType}
diff --git a/packages/desktop-client/src/components/settings/Format.js b/packages/desktop-client/src/components/settings/Format.js
index 62818239d..621412bb0 100644
--- a/packages/desktop-client/src/components/settings/Format.js
+++ b/packages/desktop-client/src/components/settings/Format.js
@@ -1,11 +1,16 @@
 import React from 'react';
 
-import { css } from 'glamor';
-
 import { numberFormats } from 'loot-core/src/shared/util';
-import { Text } from 'loot-design/src/components/common';
+import {
+  Button,
+  CustomSelect,
+  Text,
+  View,
+} from 'loot-design/src/components/common';
+import { Checkbox } from 'loot-design/src/components/forms';
+import tokens from 'loot-design/src/tokens';
 
-import { Section } from './UI';
+import { Setting } from './UI';
 
 let dateFormats = [
   { value: 'MM/dd/yyyy', label: 'MM/DD/YYYY' },
@@ -15,53 +20,95 @@ let dateFormats = [
   { value: 'dd.MM.yyyy', label: 'DD.MM.YYYY' },
 ];
 
+function Column({ title, children }) {
+  return (
+    <View
+      style={{
+        alignItems: 'flex-start',
+        gap: '0.5em',
+        flexGrow: 1,
+        [`@media (max-width: ${tokens.breakpoint_xs})`]: {
+          width: '100%',
+        },
+      }}
+    >
+      <Text style={{ fontWeight: 500 }}>{title}</Text>
+      <View style={{ alignItems: 'flex-start', gap: '1em' }}>{children}</View>
+    </View>
+  );
+}
+
 export default function FormatSettings({ prefs, savePrefs }) {
-  function onDateFormat(e) {
-    let format = e.target.value;
+  function onDateFormat(format) {
     savePrefs({ dateFormat: format });
   }
 
-  function onNumberFormat(e) {
-    let format = e.target.value;
+  function onNumberFormat(format) {
     savePrefs({ numberFormat: format });
   }
 
+  function onHideFraction(e) {
+    let hideFraction = e.target.checked;
+    savePrefs({ hideFraction });
+  }
+
   let dateFormat = prefs.dateFormat || 'MM/dd/yyyy';
   let numberFormat = prefs.numberFormat || 'comma-dot';
 
   return (
-    <Section title="Formatting">
-      <Text>
-        <label htmlFor="settings-numberFormat">Number format: </label>
-        <select
-          defaultValue={numberFormat}
-          id="settings-numberFormat"
-          {...css({ marginLeft: 5, fontSize: 14 })}
-          onChange={onNumberFormat}
+    <Setting
+      primaryAction={
+        <View
+          style={{
+            flexDirection: 'row',
+            gap: '1em',
+            width: '100%',
+            [`@media (max-width: ${tokens.breakpoint_xs})`]: {
+              flexDirection: 'column',
+            },
+          }}
         >
-          {numberFormats.map(f => (
-            <option key={f.value} value={f.value}>
-              {f.label}
-            </option>
-          ))}
-        </select>
-      </Text>
+          <Column title="Numbers">
+            <Button bounce={false} style={{ padding: 0 }}>
+              <CustomSelect
+                key={prefs.hideFraction} // needed because label does not update
+                value={numberFormat}
+                onChange={onNumberFormat}
+                options={numberFormats.map(f => [
+                  f.value,
+                  prefs.hideFraction ? f.labelNoFraction : f.label,
+                ])}
+                style={{ padding: '5px 10px', fontSize: 15 }}
+              />
+            </Button>
 
+            <Text style={{ display: 'flex' }}>
+              <Checkbox
+                id="settings-textDecimal"
+                checked={prefs.hideFraction}
+                onChange={onHideFraction}
+              />
+              <label htmlFor="settings-textDecimal">Hide decimal places</label>
+            </Text>
+          </Column>
+
+          <Column title="Dates">
+            <Button bounce={false} style={{ padding: 0 }}>
+              <CustomSelect
+                value={dateFormat}
+                onChange={onDateFormat}
+                options={dateFormats.map(f => [f.value, f.label])}
+                style={{ padding: '5px 10px', fontSize: 15 }}
+              />
+            </Button>
+          </Column>
+        </View>
+      }
+    >
       <Text>
-        <label htmlFor="settings-dateFormat">Date format: </label>
-        <select
-          defaultValue={dateFormat}
-          id="settings-dateFormat"
-          {...css({ marginLeft: 5, fontSize: 14 })}
-          onChange={onDateFormat}
-        >
-          {dateFormats.map(f => (
-            <option key={f.value} value={f.value}>
-              {f.label}
-            </option>
-          ))}
-        </select>
+        <strong>Formatting</strong> does not affect how budget data is stored,
+        and can be changed at any time.
       </Text>
-    </Section>
+    </Setting>
   );
 }
diff --git a/packages/loot-core/src/client/reducers/prefs.js b/packages/loot-core/src/client/reducers/prefs.js
index 56010e5d3..3513f87da 100644
--- a/packages/loot-core/src/client/reducers/prefs.js
+++ b/packages/loot-core/src/client/reducers/prefs.js
@@ -10,12 +10,21 @@ export default function update(state = initialState, action) {
   switch (action.type) {
     case constants.SET_PREFS:
       if (action.prefs) {
-        setNumberFormat(action.prefs.numberFormat || 'comma-dot');
+        setNumberFormat({
+          format: action.prefs.numberFormat || 'comma-dot',
+          hideFraction: action.prefs.hideFraction,
+        });
       }
       return { local: action.prefs, global: action.globalPrefs };
     case constants.MERGE_LOCAL_PREFS:
-      if (action.prefs.numberFormat) {
-        setNumberFormat(action.prefs.numberFormat);
+      if (action.prefs.numberFormat || action.prefs.hideFraction != null) {
+        setNumberFormat({
+          format: action.prefs.numberFormat || state.local.numberFormat,
+          hideFraction:
+            action.prefs.hideFraction != null
+              ? action.prefs.hideFraction
+              : state.local.hideFraction,
+        });
       }
 
       return {
diff --git a/packages/loot-core/src/shared/util.js b/packages/loot-core/src/shared/util.js
index 0e7671466..12745e479 100644
--- a/packages/loot-core/src/shared/util.js
+++ b/packages/loot-core/src/shared/util.js
@@ -249,9 +249,9 @@ export function titleFirst(str) {
 }
 
 export let numberFormats = [
-  { value: 'comma-dot', label: '1,000.33' },
-  { value: 'dot-comma', label: '1.000,33' },
-  { value: 'space-comma', label: '1 000,33' },
+  { value: 'comma-dot', label: '1,000.33', labelNoFraction: '1,000' },
+  { value: 'dot-comma', label: '1.000,33', labelNoFraction: '1.000' },
+  { value: 'space-comma', label: '1 000,33', labelNoFraction: '1 000' },
 ];
 
 let numberFormat = {
@@ -260,7 +260,7 @@ let numberFormat = {
   regex: null,
 };
 
-export function setNumberFormat(format) {
+export function setNumberFormat({ format, hideFraction }) {
   let locale, regex, separator;
 
   switch (format) {
@@ -285,8 +285,8 @@ export function setNumberFormat(format) {
     value: format,
     separator,
     formatter: new Intl.NumberFormat(locale, {
-      minimumFractionDigits: 2,
-      maximumFractionDigits: 2,
+      minimumFractionDigits: hideFraction ? 0 : 2,
+      maximumFractionDigits: hideFraction ? 0 : 2,
     }),
     regex,
   };
@@ -296,7 +296,7 @@ export function getNumberFormat() {
   return numberFormat;
 }
 
-setNumberFormat('comma-dot');
+setNumberFormat({ format: 'comma-dot', hideFraction: false });
 
 // Number utilities
 
diff --git a/packages/loot-core/src/shared/util.test.js b/packages/loot-core/src/shared/util.test.js
index a5a2df902..ebd52e0c0 100644
--- a/packages/loot-core/src/shared/util.test.js
+++ b/packages/loot-core/src/shared/util.test.js
@@ -30,24 +30,33 @@ describe('utility functions', () => {
   });
 
   test('number formatting works with comma-dot format', () => {
-    setNumberFormat('comma-dot');
-    const formatter = getNumberFormat().formatter;
-
+    setNumberFormat({ format: 'comma-dot', hideFraction: false });
+    let formatter = getNumberFormat().formatter;
     expect(formatter.format('1234.56')).toBe('1,234.56');
+
+    setNumberFormat({ format: 'comma-dot', hideFraction: true });
+    formatter = getNumberFormat().formatter;
+    expect(formatter.format('1234.56')).toBe('1,235');
   });
 
   test('number formatting works with dot-comma format', () => {
-    setNumberFormat('dot-comma');
-    const formatter = getNumberFormat().formatter;
-
+    setNumberFormat({ format: 'dot-comma', hideFraction: false });
+    let formatter = getNumberFormat().formatter;
     expect(formatter.format('1234.56')).toBe('1.234,56');
+
+    setNumberFormat({ format: 'dot-comma', hideFraction: true });
+    formatter = getNumberFormat().formatter;
+    expect(formatter.format('1234.56')).toBe('1.235');
   });
 
   test('number formatting works with space-comma format', () => {
-    setNumberFormat('space-comma');
-    const formatter = getNumberFormat().formatter;
-
+    setNumberFormat({ format: 'space-comma', hideFraction: false });
+    let formatter = getNumberFormat().formatter;
     // grouping separator space char is a non-breaking space, or UTF-16 \xa0
     expect(formatter.format('1234.56')).toBe('1\xa0234,56');
+
+    setNumberFormat({ format: 'space-comma', hideFraction: true });
+    formatter = getNumberFormat().formatter;
+    expect(formatter.format('1234.56')).toBe('1\xa0235');
   });
 });
diff --git a/packages/loot-design/src/components/common.js b/packages/loot-design/src/components/common.js
index c6bad8b6c..1ada04125 100644
--- a/packages/loot-design/src/components/common.js
+++ b/packages/loot-design/src/components/common.js
@@ -199,6 +199,7 @@ export const Button = React.forwardRef(
       disabled,
       hoveredStyle,
       activeStyle,
+      bounce = true,
       as = 'button',
       ...nativeProps
     },
@@ -214,7 +215,7 @@ export const Button = React.forwardRef(
       bare
         ? { backgroundColor: 'rgba(100, 100, 100, .25)' }
         : {
-            transform: 'translateY(1px)',
+            transform: bounce && 'translateY(1px)',
             boxShadow:
               !bare &&
               (primary
diff --git a/packages/loot-design/src/style.js b/packages/loot-design/src/style.js
index 7ca66f835..3a159eccc 100644
--- a/packages/loot-design/src/style.js
+++ b/packages/loot-design/src/style.js
@@ -112,8 +112,10 @@ export const styles = {
   page: {
     // This is the height of the titlebar
     paddingTop: 8,
-    minWidth: 360,
     flex: 1,
+    [`@media (min-width: ${tokens.breakpoint_xs})`]: {
+      minWidth: 360,
+    },
     [`@media (min-width: ${tokens.breakpoint_medium})`]: {
       minWidth: 500,
       paddingTop: 36,
diff --git a/packages/loot-design/src/tokens.js b/packages/loot-design/src/tokens.js
index 5521b9a38..96fca1749 100644
--- a/packages/loot-design/src/tokens.js
+++ b/packages/loot-design/src/tokens.js
@@ -1,4 +1,5 @@
 const tokens = {
+  breakpoint_xs: '350px',
   breakpoint_narrow: '512px',
   breakpoint_medium: '768px',
   breakpoint_wide: '1024px',
diff --git a/upcoming-release-notes/725.md b/upcoming-release-notes/725.md
new file mode 100644
index 000000000..39383c12b
--- /dev/null
+++ b/upcoming-release-notes/725.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [j-f1]
+---
+
+A “hide decimal places” option has been added to improve readability for currencies that typically have large values.
-- 
GitLab