From 308f8339aebf2fedd2e8fc1cb7ba110b9e55c303 Mon Sep 17 00:00:00 2001
From: Neil <55785687+carkom@users.noreply.github.com>
Date: Tue, 2 Apr 2024 20:29:20 +0100
Subject: [PATCH] Add yearly to custom reports (#2466)

* Button changes and time filters

* rename on dashboard

* notes

* fix time filters

* Sort Categories

* Page title

* category sort order

* move button

* featureflag

* Highlight report name

* sankey fix

* VRT

* remove doubled element

* adjust to match master

* add Year

* notes

* lint fix

* update names

* IntervalsUpdates

* fixing bugs

* ts updates

* lint fix

* merge fixes

* notes

* simplify lookups
---
 .../src/components/reports/ReportOptions.ts   | 41 ++++++++++---
 .../src/components/reports/ReportSidebar.jsx  | 28 +++++----
 .../src/components/reports/ReportSummary.tsx  | 15 ++++-
 .../src/components/reports/reportRanges.ts    | 32 ++--------
 .../reports/reports/CustomReport.jsx          | 61 ++++++++++++-------
 .../spreadsheets/custom-spreadsheet.ts        | 13 ++--
 .../spreadsheets/grouped-spreadsheet.ts       |  9 ++-
 .../reports/spreadsheets/makeQuery.ts         |  5 +-
 packages/loot-core/src/shared/months.ts       | 47 ++++++++++++++
 upcoming-release-notes/2466.md                |  6 ++
 10 files changed, 171 insertions(+), 86 deletions(-)
 create mode 100644 upcoming-release-notes/2466.md

diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts
index 7a7c717ca..cf6e8becd 100644
--- a/packages/desktop-client/src/components/reports/ReportOptions.ts
+++ b/packages/desktop-client/src/components/reports/ReportOptions.ts
@@ -45,22 +45,37 @@ const groupByOptions = [
 ];
 
 const dateRangeOptions = [
-  { description: 'This month', name: 0 },
-  { description: 'Last month', name: 1 },
-  { description: 'Last 3 months', name: 2 },
-  { description: 'Last 6 months', name: 5 },
-  { description: 'Last 12 months', name: 11 },
-  { description: 'Year to date', name: 'yearToDate' },
-  { description: 'Last year', name: 'lastYear' },
-  { description: 'All time', name: 'allMonths' },
+  { description: 'This month', name: 0, Yearly: false, Monthly: true },
+  { description: 'Last month', name: 1, Yearly: false, Monthly: true },
+  { description: 'Last 3 months', name: 2, Yearly: false, Monthly: true },
+  { description: 'Last 6 months', name: 5, Yearly: false, Monthly: true },
+  { description: 'Last 12 months', name: 11, Yearly: false, Monthly: true },
+  {
+    description: 'Year to date',
+    name: 'yearToDate',
+    Yearly: true,
+    Monthly: true,
+  },
+  { description: 'Last year', name: 'lastYear', Yearly: true, Monthly: true },
+  { description: 'All time', name: 'allMonths', Yearly: true, Monthly: true },
 ];
 
 const intervalOptions = [
   //{ value: 1, description: 'Daily', name: 'Day'},
   //{ value: 2, description: 'Weekly', name: 'Week'},
   //{ value: 3, description: 'Fortnightly', name: 3},
-  { value: 4, description: 'Monthly', name: 'Month' },
-  { value: 5, description: 'Yearly', name: 'Year' },
+  {
+    description: 'Monthly',
+    name: 'Month',
+    format: 'MMMM, yyyy',
+    range: 'rangeInclusive',
+  },
+  {
+    description: 'Yearly',
+    name: 'Year',
+    format: 'yyyy',
+    range: 'yearRangeInclusive',
+  },
 ];
 
 export const ReportOptions = {
@@ -77,6 +92,12 @@ export const ReportOptions = {
   intervalMap: new Map(
     intervalOptions.map(item => [item.description, item.name]),
   ),
+  intervalFormat: new Map(
+    intervalOptions.map(item => [item.description, item.format]),
+  ),
+  intervalRange: new Map(
+    intervalOptions.map(item => [item.description, item.range]),
+  ),
 };
 
 export type QueryDataEntity = {
diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.jsx b/packages/desktop-client/src/components/reports/ReportSidebar.jsx
index 4e4b3f84f..e916183c7 100644
--- a/packages/desktop-client/src/components/reports/ReportSidebar.jsx
+++ b/packages/desktop-client/src/components/reports/ReportSidebar.jsx
@@ -77,7 +77,6 @@ export function ReportSidebar({
         [dateStart, dateEnd] = getSpecificRange(
           ReportOptions.dateRangeMap.get(cond),
           cond === 'Last month' ? 0 : null,
-          customReportItems.interval,
         );
         onChangeDates(dateStart, dateEnd);
     }
@@ -209,16 +208,20 @@ export function ReportSidebar({
             onChange={e => {
               setInterval(e);
               onReportChange({ type: 'modify' });
+              if (
+                ReportOptions.dateRange
+                  .filter(int => !int[e])
+                  .map(int => int.description)
+                  .includes(customReportItems.dateRange)
+              ) {
+                onSelectRange('Year to date');
+              }
             }}
             options={ReportOptions.interval.map(option => [
               option.description,
               option.description,
             ])}
-            disabledKeys={
-              customReportItems.mode === 'time'
-                ? ['Monthly', 'Yearly']
-                : ['Yearly']
-            }
+            disabledKeys={[]}
           />
         </View>
         <View
@@ -354,11 +357,10 @@ export function ReportSidebar({
               onChange={e => {
                 onSelectRange(e);
               }}
-              options={ReportOptions.dateRange.map(option => [
-                option.description,
-                option.description,
-              ])}
-              line={dateRangeLine}
+              options={ReportOptions.dateRange
+                .filter(f => f[customReportItems.interval])
+                .map(option => [option.description, option.description])}
+              line={customReportItems.interval === 'Monthly' && dateRangeLine}
             />
           </View>
         ) : (
@@ -387,7 +389,7 @@ export function ReportSidebar({
                 value={customReportItems.startDate}
                 defaultLabel={monthUtils.format(
                   customReportItems.startDate,
-                  'MMMM, yyyy',
+                  ReportOptions.intervalFormat.get(customReportItems.interval),
                 )}
                 options={allIntervals.map(({ name, pretty }) => [name, pretty])}
               />
@@ -416,7 +418,7 @@ export function ReportSidebar({
                 value={customReportItems.endDate}
                 defaultLabel={monthUtils.format(
                   customReportItems.endDate,
-                  'MMMM, yyyy',
+                  ReportOptions.intervalFormat.get(customReportItems.interval),
                 )}
                 options={allIntervals.map(({ name, pretty }) => [name, pretty])}
               />
diff --git a/packages/desktop-client/src/components/reports/ReportSummary.tsx b/packages/desktop-client/src/components/reports/ReportSummary.tsx
index 5653a19ce..48f570c6b 100644
--- a/packages/desktop-client/src/components/reports/ReportSummary.tsx
+++ b/packages/desktop-client/src/components/reports/ReportSummary.tsx
@@ -14,6 +14,8 @@ import { Text } from '../common/Text';
 import { View } from '../common/View';
 import { PrivacyFilter } from '../PrivacyFilter';
 
+import { ReportOptions } from './ReportOptions';
+
 type ReportSummaryProps = {
   startDate: string;
   endDate: string;
@@ -59,8 +61,15 @@ export function ReportSummary({
             fontWeight: 600,
           }}
         >
-          {monthUtils.format(startDate, 'MMM yyyy')} -{' '}
-          {monthUtils.format(endDate, 'MMM yyyy')}
+          {monthUtils.format(
+            startDate,
+            ReportOptions.intervalFormat.get(interval),
+          )}{' '}
+          -{' '}
+          {monthUtils.format(
+            endDate,
+            ReportOptions.intervalFormat.get(interval),
+          )}
         </Text>
       </View>
       <View
@@ -136,7 +145,7 @@ export function ReportSummary({
           </PrivacyFilter>
         </Text>
         <Text style={{ fontWeight: 600 }}>
-          Per {interval === 'Monthly' ? 'month' : 'year'}
+          Per {ReportOptions.intervalMap.get(interval).toLowerCase()}
         </Text>
       </View>
     </View>
diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts
index ae0e1a33b..52a79c1d2 100644
--- a/packages/desktop-client/src/components/reports/reportRanges.ts
+++ b/packages/desktop-client/src/components/reports/reportRanges.ts
@@ -115,33 +115,13 @@ function boundedRange(
   return [start, end];
 }
 
-export function getSpecificRange(
-  offset: number,
-  addNumber: number,
-  interval: string,
-) {
+export function getSpecificRange(offset: number, addNumber: number) {
   const currentDay = monthUtils.currentDay();
-  let currInterval;
-  let dateStart;
-  let dateEnd;
-  switch (interval) {
-    case 'Monthly':
-      currInterval = monthUtils.monthFromDate(currentDay);
-      dateStart = monthUtils.subMonths(currInterval, offset);
-      dateEnd = monthUtils.addMonths(
-        dateStart,
-        addNumber === null ? offset : addNumber,
-      );
-      break;
-    default:
-      currInterval = currentDay;
-      dateStart = monthUtils.subDays(currInterval, offset);
-      dateEnd = monthUtils.addDays(
-        dateStart,
-        addNumber === null ? offset : addNumber,
-      );
-      break;
-  }
+  const dateStart = monthUtils.subMonths(currentDay, offset) + '-01';
+  const dateEnd = monthUtils.getMonthEnd(
+    monthUtils.addMonths(dateStart, addNumber === null ? offset : addNumber) +
+      '-01',
+  );
   return [dateStart, dateEnd];
 }
 
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
index a8c0691a1..c7e89460b 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
@@ -81,14 +81,29 @@ export function CustomReport() {
 
   const [dateRange, setDateRange] = useState(loadReport.dateRange);
   const [dataCheck, setDataCheck] = useState(false);
-  const dateRangeLine = ReportOptions.dateRange.length - 3;
+  const dateRangeLine =
+    ReportOptions.dateRange.filter(f => f[interval]).length - 3;
 
+  const [intervals, setIntervals] = useState(
+    monthUtils.rangeInclusive(startDate, endDate),
+  );
   const [earliestTransaction, setEarliestTransaction] = useState('');
   const [report, setReport] = useState(loadReport);
   const [savedStatus, setSavedStatus] = useState(
     location.state ? (location.state.report ? 'saved' : 'new') : 'new',
   );
-  const intervals = monthUtils.rangeInclusive(startDate, endDate);
+
+  useEffect(() => {
+    const format =
+      ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate';
+
+    const dateStart = monthUtils[format](startDate);
+    const dateEnd = monthUtils[format](endDate);
+
+    setIntervals(
+      monthUtils[ReportOptions.intervalRange.get(interval)](dateStart, dateEnd),
+    );
+  }, [interval, startDate, endDate]);
 
   useEffect(() => {
     if (selectedCategories === undefined && categories.list.length !== 0) {
@@ -101,31 +116,31 @@ export function CustomReport() {
       report.conditions.forEach(condition => onApplyFilter(condition));
       const trans = await send('get-earliest-transaction');
       setEarliestTransaction(trans ? trans.date : monthUtils.currentDay());
-      const currentMonth = monthUtils.currentMonth();
-      let earliestMonth = trans
-        ? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date)))
-        : currentMonth;
-
-      // Make sure the month selects are at least populates with a
-      // year's worth of months. We can undo this when we have fancier
-      // date selects.
-      const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
-      if (earliestMonth > yearAgo) {
-        earliestMonth = yearAgo;
-      }
-
-      const allInter = monthUtils
-        .rangeInclusive(earliestMonth, monthUtils.currentMonth())
-        .map(month => ({
-          name: month,
-          pretty: monthUtils.format(month, 'MMMM, yyyy'),
+      const format =
+        ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate';
+      const currentInterval =
+        monthUtils['current' + ReportOptions.intervalMap.get(interval)]();
+      const earliestInterval = trans
+        ? monthUtils[format](d.parseISO(fromDateRepr(trans.date)))
+        : currentInterval;
+
+      const allInter = monthUtils[ReportOptions.intervalRange.get(interval)](
+        earliestInterval,
+        currentInterval,
+      )
+        .map(inter => ({
+          name: inter,
+          pretty: monthUtils.format(
+            inter,
+            ReportOptions.intervalFormat.get(interval),
+          ),
         }))
         .reverse();
 
       setAllIntervals(allInter);
     }
     run();
-  }, []);
+  }, [interval]);
 
   const balanceTypeOp = ReportOptions.balanceTypeMap.get(balanceType);
   const payees = usePayees();
@@ -149,8 +164,8 @@ export function CustomReport() {
   }, [
     startDate,
     endDate,
-    groupBy,
     interval,
+    groupBy,
     balanceType,
     categories,
     selectedCategories,
@@ -189,8 +204,8 @@ export function CustomReport() {
   }, [
     startDate,
     endDate,
-    groupBy,
     interval,
+    groupBy,
     balanceType,
     categories,
     selectedCategories,
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
index 82de8084a..dc5a601e6 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
@@ -18,7 +18,11 @@ import {
   type GroupedEntity,
 } from 'loot-core/src/types/models/reports';
 
-import { categoryLists, groupBySelections } from '../ReportOptions';
+import {
+  categoryLists,
+  groupBySelections,
+  ReportOptions,
+} from '../ReportOptions';
 
 import { calculateLegend } from './calculateLegend';
 import { filterEmptyRows } from './filterEmptyRows';
@@ -123,10 +127,9 @@ export function createCustomSpreadsheet({
       ).then(({ data }) => data),
     ]);
 
-    const rangeInc =
-      interval === 'Monthly' ? 'rangeInclusive' : 'yearRangeInclusive';
-    const format = interval === 'Monthly' ? 'monthFromDate' : 'yearFromDate';
-    const intervals = monthUtils[rangeInc](
+    const format =
+      ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate';
+    const intervals = monthUtils[ReportOptions.intervalRange.get(interval)](
       monthUtils[format](startDate),
       monthUtils[format](endDate),
     );
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
index 822b53f1c..95cbf490d 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
@@ -6,7 +6,7 @@ import * as monthUtils from 'loot-core/src/shared/months';
 import { integerToAmount } from 'loot-core/src/shared/util';
 import { type DataEntity } from 'loot-core/src/types/models/reports';
 
-import { categoryLists } from '../ReportOptions';
+import { categoryLists, ReportOptions } from '../ReportOptions';
 
 import { type createCustomSpreadsheetProps } from './custom-spreadsheet';
 import { filterEmptyRows } from './filterEmptyRows';
@@ -78,10 +78,9 @@ export function createGroupedSpreadsheet({
       ).then(({ data }) => data),
     ]);
 
-    const rangeInc =
-      interval === 'Monthly' ? 'rangeInclusive' : 'yearRangeInclusive';
-    const format = interval === 'Monthly' ? 'monthFromDate' : 'yearFromDate';
-    const intervals = monthUtils[rangeInc](
+    const format =
+      ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate';
+    const intervals = monthUtils[ReportOptions.intervalRange.get(interval)](
       monthUtils[format](startDate),
       monthUtils[format](endDate),
     );
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
index 3b33809d7..31f1e8b7f 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
@@ -1,6 +1,8 @@
 import { q } from 'loot-core/src/shared/query';
 import { type CategoryEntity } from 'loot-core/src/types/models';
 
+import { ReportOptions } from '../ReportOptions';
+
 export function makeQuery(
   name: string,
   startDate: string,
@@ -13,7 +15,8 @@ export function makeQuery(
 ) {
   const intervalGroup =
     interval === 'Monthly' ? { $month: '$date' } : { $year: '$date' };
-  const intervalFilter = interval === 'Monthly' ? '$month' : '$year';
+  const intervalFilter =
+    '$' + ReportOptions.intervalMap.get(interval)?.toLowerCase() || 'month';
 
   const query = q('transactions')
     //Apply Category_Selector
diff --git a/packages/loot-core/src/shared/months.ts b/packages/loot-core/src/shared/months.ts
index 5655104ea..488e4d72c 100644
--- a/packages/loot-core/src/shared/months.ts
+++ b/packages/loot-core/src/shared/months.ts
@@ -87,6 +87,10 @@ export function monthFromDate(date: DateLike): string {
   return d.format(_parse(date), 'yyyy-MM');
 }
 
+export function weekFromDate(date: DateLike): string {
+  return d.format(_parse(date), 'yyyy-ww');
+}
+
 export function dayFromDate(date: DateLike): string {
   return d.format(_parse(date), 'yyyy-MM-dd');
 }
@@ -99,6 +103,14 @@ export function currentMonth(): string {
   }
 }
 
+export function currentYear(): string {
+  if (global.IS_TESTING || Platform.isPlaywright) {
+    return global.currentMonth || '2017';
+  } else {
+    return d.format(new Date(), 'yyyy');
+  }
+}
+
 export function currentDate(): Date {
   if (global.IS_TESTING || Platform.isPlaywright) {
     return d.parse(currentDay(), 'yyyy-MM-dd', new Date());
@@ -127,6 +139,10 @@ export function prevMonth(month: DateLike): string {
   return d.format(d.subMonths(_parse(month), 1), 'yyyy-MM');
 }
 
+export function addYears(year: DateLike, n: number): string {
+  return d.format(d.addYears(_parse(year), n), 'yyyy');
+}
+
 export function addMonths(month: DateLike, n: number): string {
   return d.format(d.addMonths(_parse(month), n), 'yyyy-MM');
 }
@@ -153,6 +169,10 @@ export function subMonths(month: string | Date, n: number) {
   return d.format(d.subMonths(_parse(month), n), 'yyyy-MM');
 }
 
+export function subYears(year: string | Date, n: number) {
+  return d.format(d.subYears(_parse(year), n), 'yyyy');
+}
+
 export function addDays(day: DateLike, n: number): string {
   return d.format(d.addDays(_parse(day), n), 'yyyy-MM-dd');
 }
@@ -178,6 +198,29 @@ export function bounds(month: DateLike): { start: number; end: number } {
   };
 }
 
+export function _yearRange(
+  start: DateLike,
+  end: DateLike,
+  inclusive = false,
+): string[] {
+  const years: string[] = [];
+  let year = yearFromDate(start);
+  while (d.isBefore(_parse(year), _parse(end))) {
+    years.push(year);
+    year = addYears(year, 1);
+  }
+
+  if (inclusive) {
+    years.push(year);
+  }
+
+  return years;
+}
+
+export function yearRangeInclusive(start: DateLike, end: DateLike): string[] {
+  return _yearRange(start, end, true);
+}
+
 export function _range(
   start: DateLike,
   end: DateLike,
@@ -249,6 +292,10 @@ export function getMonth(day: string): string {
   return day.slice(0, 7);
 }
 
+export function getMonthEnd(day: string): string {
+  return subDays(nextMonth(day.slice(0, 7)) + '-01', 1);
+}
+
 export function getYearStart(month: string): string {
   return getYear(month) + '-01';
 }
diff --git a/upcoming-release-notes/2466.md b/upcoming-release-notes/2466.md
new file mode 100644
index 000000000..17be3c75e
--- /dev/null
+++ b/upcoming-release-notes/2466.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [carkom]
+---
+
+Enable "yearly" interval to custom reports. Also sets-up groudwork for adding weekly/daily in the near future
-- 
GitLab