diff --git a/packages/desktop-client/src/components/reports/ChooseGraph.tsx b/packages/desktop-client/src/components/reports/ChooseGraph.tsx index d84829a3c9a..2a671ffc475 100644 --- a/packages/desktop-client/src/components/reports/ChooseGraph.tsx +++ b/packages/desktop-client/src/components/reports/ChooseGraph.tsx @@ -1,7 +1,6 @@ // @ts-strict-ignore import React, { useRef } from 'react'; -import * as monthUtils from 'loot-core/src/shared/months'; import { type GroupedEntity } from 'loot-core/src/types/models/reports'; import { type CSSProperties } from '../../style'; @@ -20,8 +19,6 @@ import { ReportTableTotals } from './graphs/tableGraph/ReportTableTotals'; import { ReportOptions } from './ReportOptions'; type ChooseGraphProps = { - startDate: string; - endDate: string; data: GroupedEntity; mode: string; graphType: string; @@ -34,11 +31,10 @@ type ChooseGraphProps = { style?: CSSProperties; showHiddenCategories?: boolean; showOffBudget?: boolean; + intervalsCount?: number; }; export function ChooseGraph({ - startDate, - endDate, data, mode, graphType, @@ -51,8 +47,8 @@ export function ChooseGraph({ style, showHiddenCategories, showOffBudget, + intervalsCount, }: ChooseGraphProps) { - const intervals: string[] = monthUtils.rangeInclusive(startDate, endDate); const graphStyle = compact ? { ...style } : { flexGrow: 1 }; const balanceTypeOp = ReportOptions.balanceTypeMap.get(balanceType); const groupByData = @@ -166,7 +162,7 @@ export function ChooseGraph({ groupBy={groupBy} data={data[groupByData]} mode={mode} - intervalsCount={intervals.length} + intervalsCount={intervalsCount} compact={compact} style={rowStyle} compactStyle={compactStyle} @@ -177,7 +173,7 @@ export function ChooseGraph({ data={data} mode={mode} balanceTypeOp={balanceTypeOp} - intervalsCount={intervals.length} + intervalsCount={intervalsCount} compact={compact} style={rowStyle} compactStyle={compactStyle} diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts index cf6e8becdfb..4f58c81def3 100644 --- a/packages/desktop-client/src/components/reports/ReportOptions.ts +++ b/packages/desktop-client/src/components/reports/ReportOptions.ts @@ -45,24 +45,106 @@ const groupByOptions = [ ]; const dateRangeOptions = [ - { 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: 'This week', + type: 'Weeks', + name: 0, + Yearly: false, + Monthly: false, + Daily: true, + Weekly: true, + }, + { + description: 'Last week', + type: 'Weeks', + name: 1, + Yearly: false, + Monthly: false, + Daily: true, + Weekly: true, + }, + { + description: 'This month', + type: 'Months', + name: 0, + Yearly: false, + Monthly: true, + Daily: true, + Weekly: true, + }, + { + description: 'Last month', + type: 'Months', + name: 1, + Yearly: false, + Monthly: true, + Daily: true, + Weekly: true, + }, + { + description: 'Last 3 months', + type: 'Months', + name: 2, + Yearly: false, + Monthly: true, + Daily: true, + Weekly: true, + }, + { + description: 'Last 6 months', + type: 'Months', + name: 5, + Yearly: false, + Monthly: true, + Daily: false, + }, + { + description: 'Last 12 months', + type: 'Months', + name: 11, + Yearly: false, + Monthly: true, + Daily: false, + }, { description: 'Year to date', name: 'yearToDate', Yearly: true, Monthly: true, + Daily: true, + Weekly: true, + }, + { + description: 'Last year', + name: 'lastYear', + Yearly: true, + Monthly: true, + Daily: true, + Weekly: true, + }, + { + description: 'All time', + name: 'allTime', + Yearly: true, + Monthly: true, + Daily: true, + Weekly: 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'}, + { + description: 'Daily', + name: 'Day', + format: 'yyyy-MM-dd', + range: 'dayRangeInclusive', + }, + { + description: 'Weekly', + name: 'Week', + format: 'yyyy-MM-dd', + range: 'weekRangeInclusive', + }, //{ value: 3, description: 'Fortnightly', name: 3}, { description: 'Monthly', @@ -88,6 +170,9 @@ export const ReportOptions = { dateRangeMap: new Map( dateRangeOptions.map(item => [item.description, item.name]), ), + dateRangeType: new Map( + dateRangeOptions.map(item => [item.description, item.type]), + ), interval: intervalOptions, intervalMap: new Map( intervalOptions.map(item => [item.description, item.name]), diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.jsx b/packages/desktop-client/src/components/reports/ReportSidebar.jsx index b537911bfc5..af3339562db 100644 --- a/packages/desktop-client/src/components/reports/ReportSidebar.jsx +++ b/packages/desktop-client/src/components/reports/ReportSidebar.jsx @@ -11,6 +11,7 @@ import { View } from '../common/View'; import { Tooltip } from '../tooltips'; import { CategorySelector } from './CategorySelector'; +import { defaultsList } from './disabledList'; import { getLiveRange } from './getLiveRange'; import { ModeButton } from './ModeButton'; import { ReportOptions } from './ReportOptions'; @@ -39,6 +40,7 @@ export function ReportSidebar({ defaultItems, defaultModeItems, earliestTransaction, + firstDayOfWeekIdx, }) { const [menuOpen, setMenuOpen] = useState(false); const onSelectRange = cond => { @@ -49,7 +51,9 @@ export function ReportSidebar({ ); onReportChange({ type: 'modify' }); setDateRange(cond); - onChangeDates(...getLiveRange(cond, earliestTransaction)); + onChangeDates( + ...getLiveRange(cond, earliestTransaction, firstDayOfWeekIdx), + ); }; const onChangeMode = cond => { @@ -207,7 +211,7 @@ export function ReportSidebar({ .map(int => int.description) .includes(customReportItems.dateRange) ) { - onSelectRange('Year to date'); + onSelectRange(defaultsList.intervalRange.get(e)); } }} options={ReportOptions.interval.map(option => [ @@ -396,7 +400,7 @@ export function ReportSidebar({ options={ReportOptions.dateRange .filter(f => f[customReportItems.interval]) .map(option => [option.description, option.description])} - line={customReportItems.interval === 'Monthly' && dateRangeLine} + line={dateRangeLine > 0 && dateRangeLine} /> ) : ( @@ -419,6 +423,7 @@ export function ReportSidebar({ newValue, customReportItems.endDate, customReportItems.interval, + firstDayOfWeekIdx, ), ) } @@ -448,6 +453,7 @@ export function ReportSidebar({ customReportItems.startDate, newValue, customReportItems.interval, + firstDayOfWeekIdx, ), ) } diff --git a/packages/desktop-client/src/components/reports/ReportSummary.tsx b/packages/desktop-client/src/components/reports/ReportSummary.tsx index 48f570c6b3a..790f52193ac 100644 --- a/packages/desktop-client/src/components/reports/ReportSummary.tsx +++ b/packages/desktop-client/src/components/reports/ReportSummary.tsx @@ -64,12 +64,20 @@ export function ReportSummary({ {monthUtils.format( startDate, ReportOptions.intervalFormat.get(interval), - )}{' '} - -{' '} + )} {monthUtils.format( - endDate, + startDate, ReportOptions.intervalFormat.get(interval), - )} + ) !== + monthUtils.format( + endDate, + ReportOptions.intervalFormat.get(interval), + ) && + ' to ' + + monthUtils.format( + endDate, + ReportOptions.intervalFormat.get(interval), + )} [f.description, f.defaultType])), ]), ), + intervalRange: new Map( + intervalOptions.map(item => [item.description, item.defaultRange]), + ), }; diff --git a/packages/desktop-client/src/components/reports/getLiveRange.ts b/packages/desktop-client/src/components/reports/getLiveRange.ts index a1a44274b1b..37aff3458f7 100644 --- a/packages/desktop-client/src/components/reports/getLiveRange.ts +++ b/packages/desktop-client/src/components/reports/getLiveRange.ts @@ -3,7 +3,11 @@ import * as monthUtils from 'loot-core/src/shared/months'; import { ReportOptions } from './ReportOptions'; import { getSpecificRange, validateRange } from './reportRanges'; -export function getLiveRange(cond: string, earliestTransaction: string) { +export function getLiveRange( + cond: string, + earliestTransaction: string, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +) { let dateStart; let dateEnd; const rangeName = ReportOptions.dateRangeMap.get(cond); @@ -25,7 +29,7 @@ export function getLiveRange(cond: string, earliestTransaction: string) { '-31', ); break; - case 'allMonths': + case 'allTime': dateStart = earliestTransaction; dateEnd = monthUtils.currentDay(); break; @@ -33,7 +37,9 @@ export function getLiveRange(cond: string, earliestTransaction: string) { if (typeof rangeName === 'number') { [dateStart, dateEnd] = getSpecificRange( rangeName, - cond === 'Last month' ? 0 : null, + cond === 'Last month' || cond === 'Last week' ? 0 : null, + ReportOptions.dateRangeType.get(cond), + firstDayOfWeekIdx, ); } else { break; diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts index 48892d63fd1..12a80d9d276 100644 --- a/packages/desktop-client/src/components/reports/reportRanges.ts +++ b/packages/desktop-client/src/components/reports/reportRanges.ts @@ -5,6 +5,7 @@ export function validateStart( start: string, end: string, interval?: string, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, ) { let addDays; let dateStart; @@ -35,6 +36,7 @@ export function validateStart( dateStart, interval ? end : monthUtils.monthFromDate(end), interval, + firstDayOfWeekIdx, ); } @@ -43,6 +45,7 @@ export function validateEnd( start: string, end: string, interval?: string, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, ) { let subDays; let dateEnd; @@ -73,6 +76,7 @@ export function validateEnd( interval ? start : monthUtils.monthFromDate(start), dateEnd, interval, + firstDayOfWeekIdx, ); } @@ -92,9 +96,16 @@ function boundedRange( start: string, end: string, interval?: string, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, ) { let latest; switch (interval) { + case 'Daily': + latest = monthUtils.currentDay(); + break; + case 'Weekly': + latest = monthUtils.currentWeek(firstDayOfWeekIdx); + break; case 'Monthly': latest = monthUtils.currentMonth() + '-31'; break; @@ -115,13 +126,28 @@ function boundedRange( return [start, end]; } -export function getSpecificRange(offset: number, addNumber: number | null) { +export function getSpecificRange( + offset: number, + addNumber: number | null, + type?: string, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +) { const currentDay = monthUtils.currentDay(); - const dateStart = monthUtils.subMonths(currentDay, offset) + '-01'; - const dateEnd = monthUtils.getMonthEnd( + + let dateStart = monthUtils.subMonths(currentDay, offset) + '-01'; + let dateEnd = monthUtils.getMonthEnd( monthUtils.addMonths(dateStart, addNumber === null ? offset : addNumber) + '-01', ); + + if (type === 'Weeks') { + dateStart = monthUtils.subWeeks(currentDay, offset); + dateEnd = monthUtils.getWeekEnd( + monthUtils.addWeeks(dateStart, addNumber === null ? offset : addNumber), + firstDayOfWeekIdx, + ); + } + 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 db792e71e68..527ebf375f6 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx @@ -36,6 +36,8 @@ import { fromDateRepr } from '../util'; export function CustomReport() { const categories = useCategories(); + const [_firstDayOfWeekIdx] = useLocalPref('firstDayOfWeekIdx'); + const firstDayOfWeekIdx = _firstDayOfWeekIdx || 0; const [viewLegend = false, setViewLegendPref] = useLocalPref('reportsViewLegend'); @@ -123,9 +125,12 @@ export function CustomReport() { ? monthUtils[format](d.parseISO(fromDateRepr(trans.date))) : currentInterval; + const rangeProps = + interval === 'Weekly' + ? [earliestInterval, currentInterval, firstDayOfWeekIdx] + : [earliestInterval, currentInterval]; const allInter = monthUtils[ReportOptions.intervalRange.get(interval)]( - earliestInterval, - currentInterval, + ...rangeProps, ) .map(inter => ({ name: inter, @@ -157,8 +162,12 @@ export function CustomReport() { const dateStart = monthUtils[format](startDate); const dateEnd = monthUtils[format](endDate); + const rangeProps = + interval === 'Weekly' + ? [dateStart, dateEnd, firstDayOfWeekIdx] + : [dateStart, dateEnd]; setIntervals( - monthUtils[ReportOptions.intervalRange.get(interval)](dateStart, dateEnd), + monthUtils[ReportOptions.intervalRange.get(interval)](...rangeProps), ); }, [interval, startDate, endDate]); @@ -180,6 +189,7 @@ export function CustomReport() { showHiddenCategories, showUncategorized, balanceTypeOp, + firstDayOfWeekIdx, }); }, [ startDate, @@ -198,6 +208,7 @@ export function CustomReport() { showHiddenCategories, showUncategorized, graphType, + firstDayOfWeekIdx, ]); const getGraphData = useMemo(() => { @@ -219,6 +230,7 @@ export function CustomReport() { payees, accounts, graphType, + firstDayOfWeekIdx, setDataCheck, }); }, [ @@ -238,6 +250,7 @@ export function CustomReport() { showHiddenCategories, showUncategorized, graphType, + firstDayOfWeekIdx, ]); const graphData = useReport('default', getGraphData); const groupedData = useReport('grouped', getGroupData); @@ -471,6 +484,7 @@ export function CustomReport() { defaultItems={defaultItems} defaultModeItems={defaultModeItems} earliestTransaction={earliestTransaction} + firstDayOfWeekIdx={firstDayOfWeekIdx} /> ) : ( diff --git a/packages/desktop-client/src/components/reports/reports/GetCardData.tsx b/packages/desktop-client/src/components/reports/reports/GetCardData.tsx index 44f58d36806..fe5f0c1b5f1 100644 --- a/packages/desktop-client/src/components/reports/reports/GetCardData.tsx +++ b/packages/desktop-client/src/components/reports/reports/GetCardData.tsx @@ -101,8 +101,6 @@ export function GetCardData({ return data?.data ? ( void; }; @@ -67,6 +68,7 @@ export function createCustomSpreadsheet({ payees, accounts, graphType, + firstDayOfWeekIdx, setDataCheck, }: createCustomSpreadsheetProps) { const [categoryList, categoryGroup] = categoryLists(categories); @@ -100,7 +102,7 @@ export function createCustomSpreadsheet({ }); const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; - const [assets, debts] = await Promise.all([ + let [assets, debts] = await Promise.all([ runQuery( makeQuery( 'assets', @@ -127,11 +129,33 @@ export function createCustomSpreadsheet({ ).then(({ data }) => data), ]); + if (interval === 'Weekly') { + debts = debts.map(d => { + return { + ...d, + date: monthUtils.weekFromDate(d.date, firstDayOfWeekIdx), + }; + }); + assets = assets.map(d => { + return { + ...d, + date: monthUtils.weekFromDate(d.date, firstDayOfWeekIdx), + }; + }); + } + const format = ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate'; + const rangeProps = + interval === 'Weekly' + ? [ + monthUtils[format](startDate), + monthUtils[format](endDate), + firstDayOfWeekIdx, + ] + : [monthUtils[format](startDate), monthUtils[format](endDate)]; const intervals = monthUtils[ReportOptions.intervalRange.get(interval)]( - monthUtils[format](startDate), - monthUtils[format](endDate), + ...rangeProps, ); let totalAssets = 0; 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 95cbf490d0b..b473e557754 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts @@ -27,6 +27,7 @@ export function createGroupedSpreadsheet({ showHiddenCategories, showUncategorized, balanceTypeOp, + firstDayOfWeekIdx, }: createCustomSpreadsheetProps) { const [categoryList, categoryGroup] = categoryLists(categories); @@ -51,7 +52,7 @@ export function createGroupedSpreadsheet({ }); const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; - const [assets, debts] = await Promise.all([ + let [assets, debts] = await Promise.all([ runQuery( makeQuery( 'assets', @@ -78,11 +79,33 @@ export function createGroupedSpreadsheet({ ).then(({ data }) => data), ]); + if (interval === 'Weekly') { + debts = debts.map(d => { + return { + ...d, + date: monthUtils.weekFromDate(d.date, firstDayOfWeekIdx), + }; + }); + assets = assets.map(d => { + return { + ...d, + date: monthUtils.weekFromDate(d.date, firstDayOfWeekIdx), + }; + }); + } + const format = ReportOptions.intervalMap.get(interval).toLowerCase() + 'FromDate'; + const rangeProps = + interval === 'Weekly' + ? [ + monthUtils[format](startDate), + monthUtils[format](endDate), + firstDayOfWeekIdx, + ] + : [monthUtils[format](startDate), monthUtils[format](endDate)]; const intervals = monthUtils[ReportOptions.intervalRange.get(interval)]( - monthUtils[format](startDate), - monthUtils[format](endDate), + ...rangeProps, ); const groupedData: DataEntity[] = categoryGroup.map( diff --git a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts index 31f1e8b7f8b..986738eaeeb 100644 --- a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts +++ b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts @@ -14,9 +14,15 @@ export function makeQuery( filters: unknown[], ) { const intervalGroup = - interval === 'Monthly' ? { $month: '$date' } : { $year: '$date' }; + interval === 'Monthly' + ? { $month: '$date' } + : interval === 'Yearly' + ? { $year: '$date' } + : { $day: '$date' }; const intervalFilter = - '$' + ReportOptions.intervalMap.get(interval)?.toLowerCase() || 'month'; + interval === 'Weekly' + ? '$day' + : '$' + ReportOptions.intervalMap.get(interval)?.toLowerCase() || 'month'; const query = q('transactions') //Apply Category_Selector diff --git a/packages/loot-core/src/server/aql/compiler.ts b/packages/loot-core/src/server/aql/compiler.ts index 8c6bb76b7d5..66c4e372373 100644 --- a/packages/loot-core/src/server/aql/compiler.ts +++ b/packages/loot-core/src/server/aql/compiler.ts @@ -600,6 +600,10 @@ const compileFunction = saveStack('function', (state, func) => { } // date functions + case '$day': { + validateArgLength(args, 1); + return castInput(state, args[0], 'date'); + } case '$month': { validateArgLength(args, 1); return castInput(state, args[0], 'date-month'); diff --git a/packages/loot-core/src/shared/months.ts b/packages/loot-core/src/shared/months.ts index 488e4d72cd5..49108e8cb0c 100644 --- a/packages/loot-core/src/shared/months.ts +++ b/packages/loot-core/src/shared/months.ts @@ -87,8 +87,14 @@ 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 weekFromDate( + date: DateLike, + firstDayOfWeekIdx: 0 | 1 | 2 | 3 | 4 | 5 | 6, +): string { + return d.format( + _parse(d.startOfWeek(_parse(date), { weekStartsOn: firstDayOfWeekIdx })), + 'yyyy-MM-dd', + ); } export function dayFromDate(date: DateLike): string { @@ -103,6 +109,19 @@ export function currentMonth(): string { } } +export function currentWeek( + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +): string { + if (global.IS_TESTING || Platform.isPlaywright) { + return global.currentWeek || '2017-01-01'; + } else { + return d.format( + _parse(d.startOfWeek(new Date(), { weekStartsOn: firstDayOfWeekIdx })), + 'yyyy-MM-dd', + ); + } +} + export function currentYear(): string { if (global.IS_TESTING || Platform.isPlaywright) { return global.currentMonth || '2017'; @@ -169,6 +188,10 @@ export function subMonths(month: string | Date, n: number) { return d.format(d.subMonths(_parse(month), n), 'yyyy-MM'); } +export function subWeeks(date: DateLike, n: number): string { + return d.format(d.subWeeks(_parse(date), n), 'yyyy-MM-dd'); +} + export function subYears(year: string | Date, n: number) { return d.format(d.subYears(_parse(year), n), 'yyyy'); } @@ -221,6 +244,34 @@ export function yearRangeInclusive(start: DateLike, end: DateLike): string[] { return _yearRange(start, end, true); } +export function _weekRange( + start: DateLike, + end: DateLike, + inclusive = false, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +): string[] { + const weeks: string[] = []; + let week = weekFromDate(start, firstDayOfWeekIdx); + while (d.isBefore(_parse(week), _parse(end))) { + weeks.push(week); + week = addWeeks(week, 1); + } + + if (inclusive) { + weeks.push(week); + } + + return weeks; +} + +export function weekRangeInclusive( + start: DateLike, + end: DateLike, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +): string[] { + return _weekRange(start, end, true, firstDayOfWeekIdx); +} + export function _range( start: DateLike, end: DateLike, @@ -296,6 +347,16 @@ export function getMonthEnd(day: string): string { return subDays(nextMonth(day.slice(0, 7)) + '-01', 1); } +export function getWeekEnd( + date: DateLike, + firstDayOfWeekIdx?: 0 | 1 | 2 | 3 | 4 | 5 | 6, +): string { + return d.format( + _parse(d.endOfWeek(_parse(date), { weekStartsOn: firstDayOfWeekIdx })), + 'yyyy-MM-dd', + ); +} + export function getYearStart(month: string): string { return getYear(month) + '-01'; } diff --git a/upcoming-release-notes/2483.md b/upcoming-release-notes/2483.md new file mode 100644 index 00000000000..351be9bec4d --- /dev/null +++ b/upcoming-release-notes/2483.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Add daily and weekly to custom reports interval list.