From c66d6e00f5c91d4a7dc03fd69a6299bec11463d8 Mon Sep 17 00:00:00 2001
From: Neil <55785687+carkom@users.noreply.github.com>
Date: Sat, 20 Jan 2024 10:22:28 +0000
Subject: [PATCH] Custom Reports - add schema (#2246)

* Add schema work

* notes

* merge fixes

* add to handlers

* notes update

* Update packages/loot-core/src/server/reports/app.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* review changes

* type updates

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
---
 .../src/components/common/AnchorLink.tsx      |   5 +
 .../src/components/reports/Overview.jsx       |   5 +-
 .../src/components/reports/ReportCard.tsx     |  26 ++++-
 .../src/components/reports/ReportOptions.ts   |  31 +++++-
 .../reports/reports/CustomReport.jsx          |  37 ++++---
 .../reports/reports/CustomReportCard.jsx      |   4 +-
 .../src/client/data-hooks/reports.ts          |  48 +++++++++
 .../loot-core/src/server/aql/schema/index.ts  |  20 ++++
 packages/loot-core/src/server/main.ts         |  11 +-
 packages/loot-core/src/server/reports/app.ts  | 100 ++++++++++++++++++
 .../src/server/reports/types/handlers.ts      |   9 ++
 packages/loot-core/src/types/handlers.d.ts    |   2 +
 .../loot-core/src/types/models/reports.d.ts   |  11 +-
 upcoming-release-notes/2246.md                |   6 ++
 14 files changed, 284 insertions(+), 31 deletions(-)
 create mode 100644 packages/loot-core/src/client/data-hooks/reports.ts
 create mode 100644 packages/loot-core/src/server/reports/app.ts
 create mode 100644 packages/loot-core/src/server/reports/types/handlers.ts
 create mode 100644 upcoming-release-notes/2246.md

diff --git a/packages/desktop-client/src/components/common/AnchorLink.tsx b/packages/desktop-client/src/components/common/AnchorLink.tsx
index 5defecba6..ed596b00d 100644
--- a/packages/desktop-client/src/components/common/AnchorLink.tsx
+++ b/packages/desktop-client/src/components/common/AnchorLink.tsx
@@ -3,6 +3,8 @@ import { NavLink, useMatch } from 'react-router-dom';
 
 import { css } from 'glamor';
 
+import { type CustomReportEntity } from 'loot-core/src/types/models';
+
 import { type CSSProperties, styles } from '../../style';
 
 type AnchorLinkProps = {
@@ -10,6 +12,7 @@ type AnchorLinkProps = {
   style?: CSSProperties;
   activeStyle?: CSSProperties;
   children?: ReactNode;
+  report?: CustomReportEntity;
 };
 
 export function AnchorLink({
@@ -17,12 +20,14 @@ export function AnchorLink({
   style,
   activeStyle,
   children,
+  report,
 }: AnchorLinkProps) {
   const match = useMatch({ path: to });
 
   return (
     <NavLink
       to={to}
+      state={report ? { report } : {}}
       className={`${css([
         styles.smallText,
         style,
diff --git a/packages/desktop-client/src/components/reports/Overview.jsx b/packages/desktop-client/src/components/reports/Overview.jsx
index 93fd2f26b..16a6b1e32 100644
--- a/packages/desktop-client/src/components/reports/Overview.jsx
+++ b/packages/desktop-client/src/components/reports/Overview.jsx
@@ -1,6 +1,8 @@
 import React from 'react';
 import { useSelector } from 'react-redux';
 
+import { useReports } from 'loot-core/src/client/data-hooks/reports';
+
 import { useFeatureFlag } from '../../hooks/useFeatureFlag';
 import { styles } from '../../style';
 import { View } from '../common/View';
@@ -12,6 +14,7 @@ import { NetWorthCard } from './reports/NetWorthCard';
 import { SankeyCard } from './reports/SankeyCard';
 
 export function Overview() {
+  const customReports = useReports();
   const categorySpendingReportFeatureFlag = useFeatureFlag(
     'categorySpendingReport',
   );
@@ -45,7 +48,7 @@ export function Overview() {
         {categorySpendingReportFeatureFlag && <CategorySpendingCard />}
         {sankeyFeatureFlag && <SankeyCard />}
         {customReportsFeatureFlag ? (
-          <CustomReportCard />
+          <CustomReportCard reports={customReports} />
         ) : (
           <div style={{ flex: 1 }} />
         )}
diff --git a/packages/desktop-client/src/components/reports/ReportCard.tsx b/packages/desktop-client/src/components/reports/ReportCard.tsx
index db8b6b242..ad9fe83d1 100644
--- a/packages/desktop-client/src/components/reports/ReportCard.tsx
+++ b/packages/desktop-client/src/components/reports/ReportCard.tsx
@@ -1,11 +1,26 @@
-// @ts-strict-ignore
-import React from 'react';
+import React, { type ReactNode } from 'react';
 
-import { theme } from '../../style';
+import { type CustomReportEntity } from 'loot-core/src/types/models';
+
+import { type CSSProperties, theme } from '../../style';
 import { AnchorLink } from '../common/AnchorLink';
 import { View } from '../common/View';
 
-export function ReportCard({ flex, to, style, children }) {
+type ReportCardProps = {
+  to: string;
+  report: CustomReportEntity;
+  children: ReactNode;
+  flex?: string;
+  style?: CSSProperties;
+};
+
+export function ReportCard({
+  to,
+  report,
+  children,
+  flex,
+  style,
+}: ReportCardProps) {
   const containerProps = { flex, margin: 15 };
 
   const content = (
@@ -34,7 +49,8 @@ export function ReportCard({ flex, to, style, children }) {
     return (
       <AnchorLink
         to={to}
-        style={{ textDecoration: 'none', flex, ...containerProps }}
+        report={report}
+        style={{ textDecoration: 'none', ...containerProps }}
       >
         {content}
       </AnchorLink>
diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts
index f32dbe5e1..7cc13b86e 100644
--- a/packages/desktop-client/src/components/reports/ReportOptions.ts
+++ b/packages/desktop-client/src/components/reports/ReportOptions.ts
@@ -1,11 +1,32 @@
-// @ts-strict-ignore
+import * as monthUtils from 'loot-core/src/shared/months';
 import {
+  type CustomReportEntity,
   type AccountEntity,
   type CategoryEntity,
   type CategoryGroupEntity,
   type PayeeEntity,
 } from 'loot-core/src/types/models';
 
+const startDate = monthUtils.subMonths(monthUtils.currentMonth(), 5);
+const endDate = monthUtils.currentMonth();
+
+export const defaultState: CustomReportEntity = {
+  id: undefined,
+  mode: 'total',
+  groupBy: 'Category',
+  balanceType: 'Payment',
+  showEmpty: false,
+  showOffBudgetHidden: false,
+  showUncategorized: false,
+  graphType: 'BarGraph',
+  startDate,
+  endDate,
+  selectedCategories: null,
+  isDateStatic: false,
+  conditionsOp: 'and',
+  name: 'Default',
+};
+
 const balanceTypeOptions = [
   { description: 'Payment', format: 'totalDebts' as const },
   { description: 'Deposit', format: 'totalAssets' as const },
@@ -83,7 +104,7 @@ export type UncategorizedEntity = Pick<
 
 const uncategorizedCategory: UncategorizedEntity = {
   name: 'Uncategorized',
-  id: null,
+  id: undefined,
   uncategorized_id: '1',
   hidden: false,
   is_off_budget: false,
@@ -92,7 +113,7 @@ const uncategorizedCategory: UncategorizedEntity = {
 };
 const transferCategory: UncategorizedEntity = {
   name: 'Transfers',
-  id: null,
+  id: undefined,
   uncategorized_id: '2',
   hidden: false,
   is_off_budget: false,
@@ -101,7 +122,7 @@ const transferCategory: UncategorizedEntity = {
 };
 const offBudgetCategory: UncategorizedEntity = {
   name: 'Off Budget',
-  id: null,
+  id: undefined,
   uncategorized_id: '3',
   hidden: false,
   is_off_budget: true,
@@ -118,7 +139,7 @@ type UncategorizedGroupEntity = Pick<
 
 const uncategorizedGroup: UncategorizedGroupEntity = {
   name: 'Uncategorized & Off Budget',
-  id: null,
+  id: undefined,
   hidden: false,
   categories: [uncategorizedCategory, transferCategory, offBudgetCategory],
 };
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
index 476057dca..009a66e88 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx
@@ -1,5 +1,6 @@
 import React, { useState, useEffect, useMemo } from 'react';
 import { useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
 
 import * as d from 'date-fns';
 
@@ -23,7 +24,7 @@ import { ChooseGraph } from '../ChooseGraph';
 import { Header } from '../Header';
 import { LoadingIndicator } from '../LoadingIndicator';
 import { ReportLegend } from '../ReportLegend';
-import { ReportOptions } from '../ReportOptions';
+import { ReportOptions, defaultState } from '../ReportOptions';
 import { ReportSidebar } from '../ReportSidebar';
 import { ReportSummary } from '../ReportSummary';
 import { ReportTopbar } from '../ReportTopbar';
@@ -52,26 +53,34 @@ export function CustomReport() {
     onCondOpChange,
   } = useFilters();
 
-  const [selectedCategories, setSelectedCategories] = useState(null);
+  const location = useLocation();
+  const loadReport = location.state.report ?? defaultState;
+
   const [allMonths, setAllMonths] = useState(null);
   const [typeDisabled, setTypeDisabled] = useState(['Net']);
-  const [startDate, setStartDate] = useState(
-    monthUtils.subMonths(monthUtils.currentMonth(), 5),
+
+  const [selectedCategories, setSelectedCategories] = useState(
+    loadReport.selectedCategories,
+  );
+  const [startDate, setStartDate] = useState(loadReport.startDate);
+  const [endDate, setEndDate] = useState(loadReport.endDate);
+  const [mode, setMode] = useState(loadReport.mode);
+  const [isDateStatic, setIsDateStatic] = useState(loadReport.isDateStatic);
+  const [groupBy, setGroupBy] = useState(loadReport.groupBy);
+  const [balanceType, setBalanceType] = useState(loadReport.balanceType);
+  const [showEmpty, setShowEmpty] = useState(loadReport.showEmpty);
+  const [showOffBudgetHidden, setShowOffBudgetHidden] = useState(
+    loadReport.showOffBudgetHidden,
   );
-  const [endDate, setEndDate] = useState(monthUtils.currentMonth());
+  const [showUncategorized, setShowUncategorized] = useState(
+    loadReport.showUncategorized,
+  );
+  const [graphType, setGraphType] = useState(loadReport.graphType);
 
-  const [mode, setMode] = useState('total');
-  const [isDateStatic, setIsDateStatic] = useState(false);
-  const [groupBy, setGroupBy] = useState('Category');
-  const [balanceType, setBalanceType] = useState('Payment');
-  const [showEmpty, setShowEmpty] = useState(false);
-  const [showOffBudgetHidden, setShowOffBudgetHidden] = useState(false);
-  const [showUncategorized, setShowUncategorized] = useState(false);
   const [dateRange, setDateRange] = useState('Last 6 months');
   const [dataCheck, setDataCheck] = useState(false);
-
-  const [graphType, setGraphType] = useState('BarGraph');
   const dateRangeLine = ReportOptions.dateRange.length - 3;
+
   const months = monthUtils.rangeInclusive(startDate, endDate);
 
   useEffect(() => {
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx
index 7269529b4..6a1e64572 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx
@@ -13,7 +13,7 @@ import { ReportCard } from '../ReportCard';
 import { createCustomSpreadsheet } from '../spreadsheets/custom-spreadsheet';
 import { useReport } from '../useReport';
 
-export function CustomReportCard() {
+export function CustomReportCard(reports) {
   const categories = useCategories();
 
   const endDate = monthUtils.currentMonth();
@@ -32,7 +32,7 @@ export function CustomReportCard() {
   const data = useReport('default', getGraphData);
 
   return (
-    <ReportCard flex={1} to="/reports/custom">
+    <ReportCard flex={1} to="/reports/custom" reports={reports}>
       <View>
         <View style={{ flexDirection: 'row', padding: '20px 20px 0' }}>
           <View style={{ flex: 1 }}>
diff --git a/packages/loot-core/src/client/data-hooks/reports.ts b/packages/loot-core/src/client/data-hooks/reports.ts
new file mode 100644
index 000000000..1810a91f7
--- /dev/null
+++ b/packages/loot-core/src/client/data-hooks/reports.ts
@@ -0,0 +1,48 @@
+import { useMemo } from 'react';
+
+import { q } from '../../shared/query';
+import {
+  type CustomReportData,
+  type CustomReportEntity,
+} from '../../types/models';
+import { useLiveQuery } from '../query-hooks';
+
+function toJS(rows: CustomReportData[]) {
+  const reports: CustomReportEntity[] = rows.map(row => {
+    const test: CustomReportEntity = {
+      ...row,
+      conditionsOp: row.conditions_op ?? 'and',
+      filters: row.conditions,
+    };
+    return test;
+  });
+  return reports;
+}
+
+/*
+leaving as a placeholder for saved reports implementation return an 
+empty array because "reports" db table doesn't exist yet
+*/
+export function useReports(): CustomReportEntity[] {
+  const reports: CustomReportEntity[] = toJS(
+    //useLiveQuery(() => q('reports').select('*'), []) || [],
+    useLiveQuery(() => q('transaction_filters').select('*'), []) || [],
+  );
+
+  /** Sort reports by alphabetical order */
+  function sort(reports: CustomReportEntity[]) {
+    return reports.sort((a, b) =>
+      a.name
+        .trim()
+        .localeCompare(b.name.trim(), undefined, { ignorePunctuation: true }),
+    );
+  }
+
+  //return useMemo(() => sort(reports), [reports]);
+
+  //everything below this line will be removed once db table is created
+  const order: CustomReportEntity[] = useMemo(() => sort(reports), [reports]);
+  const flag = true;
+  const emptyReports: CustomReportEntity[] = flag ? [] : order;
+  return emptyReports;
+}
diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts
index 005eada68..9d893e6b3 100644
--- a/packages/loot-core/src/server/aql/schema/index.ts
+++ b/packages/loot-core/src/server/aql/schema/index.ts
@@ -125,6 +125,26 @@ export const schema = {
     conditions: f('json'),
     tombstone: f('boolean'),
   },
+  custom_reports: {
+    id: f('id'),
+    name: f('string'),
+    start_date: f('string', { default: '2023-06' }),
+    end_date: f('string', { default: '2023-09' }),
+    mode: f('string', { default: 'total' }),
+    group_by: f('string', { default: 'Category' }),
+    balance_type: f('string', { default: 'Expense' }),
+    interval: f('string', { default: 'Monthly' }),
+    show_empty: f('integer', { default: 0 }),
+    show_offbudgethidden: f('integer', { default: 0 }),
+    show_uncategorized: f('integer', { default: 0 }),
+    selected_categories: f('json'),
+    graph_type: f('string', { default: 'BarGraph' }),
+    conditions: f('json'),
+    conditions_op: f('string'),
+    metadata: f('json'),
+    color_scheme: f('json'),
+    tombstone: f('boolean'),
+  },
   reflect_budgets: {
     id: f('id'),
     month: f('integer'),
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index 03d661f06..89bf08968 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -48,6 +48,7 @@ import { app as notesApp } from './notes/app';
 import * as Platform from './platform';
 import { get, post } from './post';
 import * as prefs from './prefs';
+import { app as reportsApp } from './reports/app';
 import { app as rulesApp } from './rules/app';
 import { app as schedulesApp } from './schedules/app';
 import { getServer, setServer } from './server-config';
@@ -2147,7 +2148,15 @@ injectAPI.override((name, args) => runHandler(app.handlers[name], args));
 
 // A hack for now until we clean up everything
 app.handlers = handlers;
-app.combine(schedulesApp, budgetApp, notesApp, toolsApp, filtersApp, rulesApp);
+app.combine(
+  schedulesApp,
+  budgetApp,
+  notesApp,
+  toolsApp,
+  filtersApp,
+  reportsApp,
+  rulesApp,
+);
 
 function getDefaultDocumentDir() {
   if (Platform.isMobile) {
diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts
new file mode 100644
index 000000000..612bc23ba
--- /dev/null
+++ b/packages/loot-core/src/server/reports/app.ts
@@ -0,0 +1,100 @@
+import { v4 as uuidv4 } from 'uuid';
+
+import {
+  type CustomReportData,
+  type CustomReportEntity,
+} from '../../types/models';
+import { parseConditionsOrActions } from '../accounts/transaction-rules';
+import { createApp } from '../app';
+import * as db from '../db';
+import { requiredFields } from '../models';
+import { mutator } from '../mutators';
+import { undoable } from '../undo';
+
+import { ReportsHandlers } from './types/handlers';
+
+const reportModel = {
+  validate(report: CustomReportEntity, { update }: { update?: boolean } = {}) {
+    requiredFields('reports', report, ['conditions'], update);
+
+    if (!update || 'conditionsOp' in report) {
+      if (!['and', 'or'].includes(report.conditionsOp)) {
+        throw new Error('Invalid filter conditionsOp: ' + report.conditionsOp);
+      }
+    }
+
+    return report;
+  },
+
+  toJS(row: CustomReportData) {
+    return {
+      ...row,
+      conditionsOp: row.conditions_op,
+      filters: parseConditionsOrActions(row.conditions),
+    };
+  },
+
+  fromJS(report: CustomReportEntity) {
+    const { filters, conditionsOp, ...row }: CustomReportData = report;
+    if (conditionsOp) {
+      row.conditions_op = conditionsOp;
+      row.conditions = filters;
+    }
+    return row;
+  },
+};
+
+async function reportNameExists(
+  name: string,
+  reportId: string | undefined,
+  newItem: boolean,
+) {
+  if (!name) {
+    throw new Error('Report name is required');
+  }
+
+  if (!reportId) {
+    throw new Error('Report recall error');
+  }
+
+  const idForName: { id: string } = await db.first(
+    'SELECT id from reports WHERE tombstone = 0 AND name = ?',
+    [name],
+  );
+
+  if (!newItem && idForName.id !== reportId) {
+    throw new Error('There is already a report named ' + name);
+  }
+}
+
+async function createReport(report: CustomReportEntity) {
+  const reportId = uuidv4();
+  const item: CustomReportData = {
+    ...report,
+    id: reportId,
+  };
+
+  reportNameExists(item.name, item.id, true);
+
+  // Create the report here based on the info
+  await db.insertWithSchema('reports', reportModel.fromJS(item));
+
+  return reportId;
+}
+
+async function updateReport(item: CustomReportEntity) {
+  reportNameExists(item.name, item.id, false);
+
+  await db.insertWithSchema('reports', reportModel.fromJS(item));
+}
+
+async function deleteReport(id: string) {
+  await db.delete_('reports', id);
+}
+
+// Expose functions to the client
+export const app = createApp<ReportsHandlers>();
+
+app.method('report/create', mutator(undoable(createReport)));
+app.method('report/update', mutator(undoable(updateReport)));
+app.method('report/delete', mutator(undoable(deleteReport)));
diff --git a/packages/loot-core/src/server/reports/types/handlers.ts b/packages/loot-core/src/server/reports/types/handlers.ts
new file mode 100644
index 000000000..4c8d3fbc6
--- /dev/null
+++ b/packages/loot-core/src/server/reports/types/handlers.ts
@@ -0,0 +1,9 @@
+import { type CustomReportEntity } from '../../../types/models';
+
+export interface ReportsHandlers {
+  'report/create': (report: CustomReportEntity) => Promise<string>;
+
+  'report/update': (report: CustomReportEntity) => Promise<void>;
+
+  'report/delete': (id: string) => Promise<void>;
+}
diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts
index 7d131d445..5eb8f281a 100644
--- a/packages/loot-core/src/types/handlers.d.ts
+++ b/packages/loot-core/src/types/handlers.d.ts
@@ -1,6 +1,7 @@
 import type { BudgetHandlers } from '../server/budget/types/handlers';
 import type { FiltersHandlers } from '../server/filters/types/handlers';
 import type { NotesHandlers } from '../server/notes/types/handlers';
+import type { ReportsHandlers } from '../server/reports/types/handlers';
 import type { RulesHandlers } from '../server/rules/types/handlers';
 import type { SchedulesHandlers } from '../server/schedules/types/handlers';
 import type { ToolsHandlers } from '../server/tools/types/handlers';
@@ -14,6 +15,7 @@ export interface Handlers
     BudgetHandlers,
     FiltersHandlers,
     NotesHandlers,
+    ReportsHandlers,
     RulesHandlers,
     SchedulesHandlers,
     ToolsHandlers {}
diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts
index 7d15406b0..588b475a3 100644
--- a/packages/loot-core/src/types/models/reports.d.ts
+++ b/packages/loot-core/src/types/models/reports.d.ts
@@ -1,7 +1,7 @@
 import { type RuleConditionEntity } from './rule';
 
 export interface CustomReportEntity {
-  reportId?: string;
+  id: string | undefined;
   mode: string;
   groupBy: string;
   balanceType: string;
@@ -10,13 +10,13 @@ export interface CustomReportEntity {
   showUncategorized: boolean;
   graphType: string;
   selectedCategories;
-  filters: RuleConditionEntity;
+  filters?: RuleConditionEntity[];
   conditionsOp: string;
   name: string;
   startDate: string;
   endDate: string;
   isDateStatic: boolean;
-  data: GroupedEntity;
+  data?: GroupedEntity;
   tombstone?: boolean;
 }
 
@@ -67,3 +67,8 @@ export interface DataEntity {
 export type Month = {
   month: string;
 };
+
+export interface CustomReportData extends CustomReportEntity {
+  conditions_op?: string;
+  conditions?: RuleConditionEntity[];
+}
diff --git a/upcoming-release-notes/2246.md b/upcoming-release-notes/2246.md
new file mode 100644
index 000000000..e3bf2978e
--- /dev/null
+++ b/upcoming-release-notes/2246.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [carkom]
+---
+
+Add schema and backend functionality for custom reports. This is to enable saving reports in a future PR.
-- 
GitLab