mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Calendar Report (#3828)
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { breakpoints } from '../../tokens';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Menu } from '../common/Menu';
|
||||
@@ -33,6 +34,7 @@ import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
import { CalendarCard } from './reports/CalendarCard';
|
||||
import { CashFlowCard } from './reports/CashFlowCard';
|
||||
import { CustomReportListCards } from './reports/CustomReportListCards';
|
||||
import { MarkdownCard } from './reports/MarkdownCard';
|
||||
@@ -50,6 +52,8 @@ function isCustomReportWidget(widget: Widget): widget is CustomReportWidget {
|
||||
export function Overview() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const extraMenuTriggerRef = useRef(null);
|
||||
@@ -385,6 +389,10 @@ export function Overview() {
|
||||
name: 'summary-card' as const,
|
||||
text: t('Summary card'),
|
||||
},
|
||||
{
|
||||
name: 'calendar-card' as const,
|
||||
text: t('Calendar card'),
|
||||
},
|
||||
{
|
||||
name: 'custom-report' as const,
|
||||
text: t('New custom report'),
|
||||
@@ -534,6 +542,15 @@ export function Overview() {
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
/>
|
||||
) : item.type === 'calendar-card' ? (
|
||||
<CalendarCard
|
||||
widgetId={item.i}
|
||||
isEditing={isEditing}
|
||||
meta={item.meta}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { Overview } from './Overview';
|
||||
import { Calendar } from './reports/Calendar';
|
||||
import { CashFlow } from './reports/CashFlow';
|
||||
import { CustomReport } from './reports/CustomReport';
|
||||
import { NetWorth } from './reports/NetWorth';
|
||||
@@ -22,6 +23,8 @@ export function ReportRouter() {
|
||||
<Route path="/spending/:id" element={<Spending />} />
|
||||
<Route path="/summary" element={<Summary />} />
|
||||
<Route path="/summary/:id" element={<Summary />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/calendar/:id" element={<Calendar />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { type Ref, useEffect, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import {
|
||||
addDays,
|
||||
format,
|
||||
getDate,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from 'date-fns';
|
||||
|
||||
import { amountToCurrency } from 'loot-core/shared/util';
|
||||
import { type SyncedPrefs } from 'loot-core/types/prefs';
|
||||
|
||||
import { useResizeObserver } from '../../../hooks/useResizeObserver';
|
||||
import { styles, theme } from '../../../style';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Tooltip } from '../../common/Tooltip';
|
||||
import { View } from '../../common/View';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
import { chartTheme } from '../chart-theme';
|
||||
|
||||
type CalendarGraphProps = {
|
||||
data: {
|
||||
date: Date;
|
||||
incomeValue: number;
|
||||
expenseValue: number;
|
||||
incomeSize: number;
|
||||
expenseSize: number;
|
||||
}[];
|
||||
start: Date;
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
||||
onDayClick: (date: Date | null) => void;
|
||||
};
|
||||
export function CalendarGraph({
|
||||
data,
|
||||
start,
|
||||
firstDayOfWeekIdx,
|
||||
onDayClick,
|
||||
}: CalendarGraphProps) {
|
||||
const startingDate = startOfWeek(new Date(), {
|
||||
weekStartsOn:
|
||||
firstDayOfWeekIdx !== undefined &&
|
||||
!Number.isNaN(parseInt(firstDayOfWeekIdx)) &&
|
||||
parseInt(firstDayOfWeekIdx) >= 0 &&
|
||||
parseInt(firstDayOfWeekIdx) <= 6
|
||||
? (parseInt(firstDayOfWeekIdx) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: 0,
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(14);
|
||||
|
||||
const buttonRef = useResizeObserver(rect => {
|
||||
const newValue = Math.floor(rect.height / 2);
|
||||
if (newValue > 14) {
|
||||
setFontSize(14);
|
||||
} else {
|
||||
setFontSize(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
color: theme.pageTextSubdued,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gridAutoRows: '1fr',
|
||||
gap: 2,
|
||||
}}
|
||||
onClick={() => onDayClick(null)}
|
||||
>
|
||||
{Array.from({ length: 7 }, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
padding: '3px 0',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{format(addDays(startingDate, index), 'EEEEE')}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gridAutoRows: '1fr',
|
||||
gap: 2,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{data.map((day, index) =>
|
||||
!isSameMonth(day.date, startOfMonth(start)) ? (
|
||||
<View
|
||||
key={`empty-${day.date.getTime()}`}
|
||||
onClick={() => onDayClick(null)}
|
||||
/>
|
||||
) : day.incomeValue !== 0 || day.expenseValue !== 0 ? (
|
||||
<Tooltip
|
||||
key={day.date.getTime()}
|
||||
content={
|
||||
<View>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
<strong>{format(day.date, 'MMM dd')}</strong>
|
||||
</View>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr 60px',
|
||||
gridAutoRows: '1fr',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Income</Trans>:
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.blue,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
{day.incomeValue !== 0 ? (
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(day.incomeValue)}
|
||||
</PrivacyFilter>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
<View style={{ marginLeft: 4, flexDirection: 'row' }}>
|
||||
(
|
||||
<PrivacyFilter>
|
||||
{Math.round(day.incomeSize * 100) / 100 + '%'}
|
||||
</PrivacyFilter>
|
||||
)
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Expenses</Trans>:
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.red,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
{day.expenseValue !== 0 ? (
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(day.expenseValue)}
|
||||
</PrivacyFilter>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
<View style={{ marginLeft: 4, flexDirection: 'row' }}>
|
||||
(
|
||||
<PrivacyFilter>
|
||||
{Math.round(day.expenseSize * 100) / 100 + '%'}
|
||||
</PrivacyFilter>
|
||||
)
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
placement="bottom end"
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
lineHeight: 1.5,
|
||||
padding: '6px 10px',
|
||||
}}
|
||||
>
|
||||
<DayButton
|
||||
key={day.date.getTime()}
|
||||
resizeRef={el => {
|
||||
if (index === 15 && el) {
|
||||
buttonRef(el);
|
||||
}
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
day={day}
|
||||
onPress={() => onDayClick(day.date)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DayButton
|
||||
key={day.date.getTime()}
|
||||
resizeRef={el => {
|
||||
if (index === 15 && el) {
|
||||
buttonRef(el);
|
||||
}
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
day={day}
|
||||
onPress={() => onDayClick(day.date)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DayButtonProps = {
|
||||
fontSize: number;
|
||||
resizeRef: Ref<HTMLButtonElement>;
|
||||
day: {
|
||||
date: Date;
|
||||
incomeSize: number;
|
||||
expenseSize: number;
|
||||
};
|
||||
onPress: () => void;
|
||||
};
|
||||
function DayButton({ day, onPress, fontSize, resizeRef }: DayButtonProps) {
|
||||
const [currentFontSize, setCurrentFontSize] = useState(fontSize);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentFontSize(fontSize);
|
||||
}, [fontSize]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={resizeRef}
|
||||
aria-label={format(day.date, 'MMMM d, yyyy')}
|
||||
style={{
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: theme.calendarCellBackground,
|
||||
position: 'relative',
|
||||
padding: 'unset',
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
onPress={() => onPress()}
|
||||
>
|
||||
{day.expenseSize !== 0 && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
background: chartTheme.colors.red,
|
||||
opacity: 0.2,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{day.incomeSize !== 0 && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
background: chartTheme.colors.blue,
|
||||
opacity: 0.2,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.9,
|
||||
height: `${Math.ceil(day.incomeSize)}%`,
|
||||
backgroundColor: chartTheme.colors.blue,
|
||||
width: '50%',
|
||||
transition: 'height 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.9,
|
||||
height: `${Math.ceil(day.expenseSize)}%`,
|
||||
backgroundColor: chartTheme.colors.red,
|
||||
width: '50%',
|
||||
transition: 'height 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${currentFontSize}px`,
|
||||
fontWeight: 500,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{getDate(day.date)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +161,10 @@ export function getFullRange(start: string) {
|
||||
|
||||
export function getLatestRange(offset: number) {
|
||||
const end = monthUtils.currentMonth();
|
||||
const start = monthUtils.subMonths(end, offset);
|
||||
let start = end;
|
||||
if (offset !== 1) {
|
||||
start = monthUtils.subMonths(end, offset);
|
||||
}
|
||||
return [start, end, 'sliding-window'] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Ref,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSpring, animated, config } from 'react-spring';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules';
|
||||
import { useTransactions } from 'loot-core/client/data-hooks/transactions';
|
||||
import { useWidget } from 'loot-core/client/data-hooks/widget';
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { q, type Query } from 'loot-core/shared/query';
|
||||
import { ungroupTransactions } from 'loot-core/shared/transactions';
|
||||
import { amountToCurrency } from 'loot-core/shared/util';
|
||||
import { addNotification } from 'loot-core/src/client/actions';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import {
|
||||
type RuleConditionEntity,
|
||||
type CalendarWidget,
|
||||
type TimeFrame,
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useAccounts } from '../../../hooks/useAccounts';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../../hooks/useDateFormat';
|
||||
import { useFilters } from '../../../hooks/useFilters';
|
||||
import { useMergedRefs } from '../../../hooks/useMergedRefs';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { usePayees } from '../../../hooks/usePayees';
|
||||
import { useResizeObserver } from '../../../hooks/useResizeObserver';
|
||||
import { SelectedProviderWithItems } from '../../../hooks/useSelected';
|
||||
import { SplitsExpandedProvider } from '../../../hooks/useSplitsExpanded';
|
||||
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
||||
import {
|
||||
SvgArrowThickDown,
|
||||
SvgArrowThickUp,
|
||||
SvgCheveronDown,
|
||||
SvgCheveronUp,
|
||||
} from '../../../icons/v1';
|
||||
import { styles, theme } from '../../../style';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { View } from '../../common/View';
|
||||
import { EditablePageHeaderTitle } from '../../EditablePageHeaderTitle';
|
||||
import { MobileBackButton } from '../../mobile/MobileBackButton';
|
||||
import { TransactionList as TransactionListMobile } from '../../mobile/transactions/TransactionList';
|
||||
import { MobilePageHeader, Page, PageHeader } from '../../Page';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
import { useResponsive } from '../../responsive/ResponsiveProvider';
|
||||
import { TransactionList } from '../../transactions/TransactionList';
|
||||
import { chartTheme } from '../chart-theme';
|
||||
import { DateRange } from '../DateRange';
|
||||
import { CalendarGraph } from '../graphs/CalendarGraph';
|
||||
import { Header } from '../Header';
|
||||
import { LoadingIndicator } from '../LoadingIndicator';
|
||||
import { calculateTimeRange } from '../reportRanges';
|
||||
import {
|
||||
type CalendarDataType,
|
||||
calendarSpreadsheet,
|
||||
} from '../spreadsheets/calendar-spreadsheet';
|
||||
import { useReport } from '../useReport';
|
||||
import { fromDateRepr } from '../util';
|
||||
|
||||
const CHEVRON_HEIGHT = 42;
|
||||
const SUMMARY_HEIGHT = 140;
|
||||
|
||||
export function Calendar() {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: widget, isLoading } = useWidget<CalendarWidget>(
|
||||
params.id ?? '',
|
||||
'calendar-card',
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <CalendarInner widget={widget} parameters={searchParams} />;
|
||||
}
|
||||
|
||||
type CalendarInnerProps = {
|
||||
widget?: CalendarWidget;
|
||||
parameters: URLSearchParams;
|
||||
};
|
||||
|
||||
function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
);
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [query, setQuery] = useState<Query | undefined>(undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const { transactions: transactionsGrouped, loadMore: loadMoreTransactions } =
|
||||
useTransactions({ query });
|
||||
|
||||
const allTransactions = useMemo(
|
||||
() => ungroupTransactions(transactionsGrouped as TransactionEntity[]),
|
||||
[transactionsGrouped],
|
||||
);
|
||||
|
||||
const accounts = useAccounts();
|
||||
const payees = usePayees();
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
const {
|
||||
conditions,
|
||||
conditionsOp,
|
||||
onApply: onApplyFilter,
|
||||
onDelete: onDeleteFilter,
|
||||
onUpdate: onUpdateFilter,
|
||||
onConditionsOpChange,
|
||||
} = useFilters(widget?.meta?.conditions, widget?.meta?.conditionsOp);
|
||||
|
||||
useEffect(() => {
|
||||
const day = parameters.get('day');
|
||||
const month = parameters.get('month');
|
||||
|
||||
if (day && onApplyFilter) {
|
||||
onApplyFilter({
|
||||
conditions: [
|
||||
...(widget?.meta?.conditions || []),
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: day,
|
||||
} as RuleConditionEntity,
|
||||
],
|
||||
conditionsOp: 'and',
|
||||
id: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (month && onApplyFilter) {
|
||||
onApplyFilter({
|
||||
conditions: [
|
||||
...(widget?.meta?.conditions || []),
|
||||
{
|
||||
field: 'date',
|
||||
op: 'is',
|
||||
value: month,
|
||||
options: {
|
||||
month: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
conditionsOp: 'and',
|
||||
id: [],
|
||||
});
|
||||
}
|
||||
}, [widget?.meta?.conditions, onApplyFilter, parameters]);
|
||||
|
||||
const params = useMemo(() => {
|
||||
if (dirty === true) {
|
||||
setDirty(false);
|
||||
}
|
||||
|
||||
return calendarSpreadsheet(
|
||||
start,
|
||||
end,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
firstDayOfWeekIdx,
|
||||
);
|
||||
}, [start, end, conditions, conditionsOp, firstDayOfWeekIdx, dirty]);
|
||||
|
||||
const [sortField, setSortField] = useState('');
|
||||
const [ascDesc, setAscDesc] = useState('desc');
|
||||
|
||||
useEffect(() => {
|
||||
const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
|
||||
|
||||
send('make-filters-from-conditions', {
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
})
|
||||
.then((data: { filters: unknown[] }) => {
|
||||
let query = q('transactions')
|
||||
.filter({
|
||||
[conditionsOpKey]: data.filters,
|
||||
})
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: monthUtils.firstDayOfMonth(start) } },
|
||||
{ date: { $lte: monthUtils.lastDayOfMonth(end) } },
|
||||
],
|
||||
})
|
||||
.select('*');
|
||||
|
||||
if (sortField) {
|
||||
query = query.orderBy({
|
||||
[getField(sortField)]: ascDesc,
|
||||
});
|
||||
}
|
||||
|
||||
setQuery(query.options({ splits: 'grouped' }));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Error generating filters:', error);
|
||||
});
|
||||
}, [start, end, conditions, conditionsOp, sortField, ascDesc]);
|
||||
|
||||
const [flexAlignment, setFlexAlignment] = useState('center');
|
||||
const scrollbarContainer = useRef<HTMLDivElement>(null);
|
||||
const ref = useResizeObserver(() => {
|
||||
setFlexAlignment(
|
||||
scrollbarContainer.current &&
|
||||
scrollbarContainer.current.scrollWidth >
|
||||
scrollbarContainer.current.clientWidth
|
||||
? 'flex-start'
|
||||
: 'center',
|
||||
);
|
||||
});
|
||||
const mergedRef = useMergedRefs(
|
||||
ref,
|
||||
scrollbarContainer,
|
||||
) as Ref<HTMLDivElement>;
|
||||
|
||||
const data = useReport('calendar', params);
|
||||
|
||||
const [allMonths, setAllMonths] = useState<
|
||||
Array<{
|
||||
name: string;
|
||||
pretty: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
try {
|
||||
const trans = await send('get-earliest-transaction');
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
let earliestMonth = trans
|
||||
? monthUtils.monthFromDate(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 allMonths = monthUtils
|
||||
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
|
||||
.map(month => ({
|
||||
name: month,
|
||||
pretty: monthUtils.format(month, 'MMMM, yyyy'),
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
setAllMonths(allMonths);
|
||||
} catch (error) {
|
||||
console.error('Error fetching earliest transaction:', error);
|
||||
}
|
||||
}
|
||||
run();
|
||||
}, []);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const title = widget?.meta?.name || t('Calendar');
|
||||
const table = useRef(null);
|
||||
const dateFormat = useDateFormat();
|
||||
|
||||
const onSaveWidgetName = async (newName: string) => {
|
||||
if (!widget) {
|
||||
throw new Error('No widget that could be saved.');
|
||||
}
|
||||
|
||||
const name = newName || t('Calendar');
|
||||
await send('dashboard-update-widget', {
|
||||
id: widget.id,
|
||||
meta: {
|
||||
...(widget.meta ?? {}),
|
||||
name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
|
||||
setStart(start);
|
||||
setEnd(end);
|
||||
setMode(mode);
|
||||
}
|
||||
|
||||
async function onSaveWidget() {
|
||||
if (!widget) {
|
||||
throw new Error('No widget that could be saved.');
|
||||
}
|
||||
|
||||
try {
|
||||
await send('dashboard-update-widget', {
|
||||
id: widget.id,
|
||||
meta: {
|
||||
...(widget.meta ?? {}),
|
||||
conditions,
|
||||
conditionsOp,
|
||||
timeFrame: {
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
},
|
||||
},
|
||||
});
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'message',
|
||||
message: t('Dashboard widget successfully saved.'),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: t('Failed to save dashboard widget.'),
|
||||
}),
|
||||
);
|
||||
console.error('Error saving widget:', error);
|
||||
}
|
||||
}
|
||||
const { totalIncome, totalExpense } = useMemo(() => {
|
||||
if (!data || !data.calendarData) {
|
||||
return { totalIncome: 0, totalExpense: 0 };
|
||||
}
|
||||
return {
|
||||
totalIncome: data.calendarData.reduce(
|
||||
(prev, cur) => prev + cur.totalIncome,
|
||||
0,
|
||||
),
|
||||
totalExpense: data.calendarData.reduce(
|
||||
(prev, cur) => prev + cur.totalExpense,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const onSort = useCallback(
|
||||
(headerClicked: string, ascDesc: 'asc' | 'desc') => {
|
||||
if (headerClicked === sortField) {
|
||||
setAscDesc(ascDesc);
|
||||
} else {
|
||||
setSortField(headerClicked);
|
||||
setAscDesc('desc');
|
||||
}
|
||||
},
|
||||
[sortField],
|
||||
);
|
||||
|
||||
const onOpenTransaction = useCallback(
|
||||
(transaction: TransactionEntity) => {
|
||||
navigate(`/transactions/${transaction.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const refContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (refContainer.current) {
|
||||
setTotalHeight(refContainer.current.clientHeight - SUMMARY_HEIGHT);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const [totalHeight, setTotalHeight] = useState(0);
|
||||
const closeY = useRef(3000);
|
||||
|
||||
const openY = 0;
|
||||
const [mobileTransactionsOpen, setMobileTransactionsOpen] = useState(false);
|
||||
|
||||
const [{ y }, api] = useSpring(() => ({
|
||||
y: closeY.current,
|
||||
immediate: false,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
closeY.current = totalHeight;
|
||||
api.start({
|
||||
y: mobileTransactionsOpen ? openY : closeY.current,
|
||||
immediate: false,
|
||||
});
|
||||
}, [totalHeight, mobileTransactionsOpen, api]);
|
||||
|
||||
const open = useCallback(
|
||||
({ canceled }: { canceled: boolean }) => {
|
||||
api.start({
|
||||
y: openY,
|
||||
immediate: false,
|
||||
config: canceled ? config.wobbly : config.stiff,
|
||||
});
|
||||
setMobileTransactionsOpen(true);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
(velocity = 0) => {
|
||||
api.start({
|
||||
y: closeY.current,
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
setMobileTransactionsOpen(false);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const bind = useDrag(
|
||||
({ offset: [, oy], cancel }) => {
|
||||
if (oy < 0) {
|
||||
cancel();
|
||||
api.start({ y: 0, immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oy > totalHeight * 0.05 && mobileTransactionsOpen) {
|
||||
cancel();
|
||||
close();
|
||||
setMobileTransactionsOpen(false);
|
||||
} else if (!mobileTransactionsOpen) {
|
||||
if (oy / totalHeight > 0.05) {
|
||||
cancel();
|
||||
open({ canceled: true });
|
||||
setMobileTransactionsOpen(true);
|
||||
} else {
|
||||
api.start({ y: oy, immediate: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [0, y.get()],
|
||||
filterTaps: true,
|
||||
bounds: {
|
||||
top: -totalHeight + CHEVRON_HEIGHT,
|
||||
bottom: totalHeight - CHEVRON_HEIGHT,
|
||||
},
|
||||
axis: 'y',
|
||||
rubberband: true,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
isNarrowWidth ? (
|
||||
<MobilePageHeader
|
||||
title={title}
|
||||
leftContent={
|
||||
<MobileBackButton onPress={() => navigate('/reports')} />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PageHeader
|
||||
title={
|
||||
widget ? (
|
||||
<EditablePageHeaderTitle
|
||||
title={title}
|
||||
onSave={onSaveWidgetName}
|
||||
/>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
padding={0}
|
||||
>
|
||||
<View style={{ minHeight: !isNarrowWidth ? '120px' : 'unset' }}>
|
||||
<Header
|
||||
allMonths={allMonths}
|
||||
start={start}
|
||||
end={end}
|
||||
mode={mode}
|
||||
onChangeDates={onChangeDates}
|
||||
filters={conditions}
|
||||
onApply={onApplyFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
show1Month={true}
|
||||
>
|
||||
{widget && (
|
||||
<Button variant="primary" onPress={onSaveWidget}>
|
||||
<Trans>Save widget</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Header>
|
||||
</View>
|
||||
<View ref={refContainer as Ref<HTMLDivElement>} style={{ flexGrow: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.pageBackground,
|
||||
paddingTop: 0,
|
||||
minHeight: '350px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: isNarrowWidth ? 'column-reverse' : 'row',
|
||||
justifyContent: 'flex-start',
|
||||
flexGrow: 1,
|
||||
gap: 16,
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{data && (
|
||||
<View
|
||||
ref={mergedRef}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
gap: '20px',
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
justifyContent: flexAlignment,
|
||||
display: 'flex',
|
||||
...styles.horizontalScrollbar,
|
||||
}}
|
||||
>
|
||||
{data.calendarData.map((calendar, index) => (
|
||||
<CalendarWithHeader
|
||||
key={index}
|
||||
calendar={calendar}
|
||||
onApplyFilter={onApplyFilter}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<CalendarCardHeader
|
||||
start={start}
|
||||
end={end}
|
||||
totalExpense={totalExpense}
|
||||
totalIncome={totalIncome}
|
||||
isNarrowWidth={isNarrowWidth}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={[]}
|
||||
fetchAllIds={async () => []}
|
||||
registerDispatch={() => {}}
|
||||
selectAllFilter={(item: TransactionEntity) =>
|
||||
!item._unmatched && !item.is_parent
|
||||
}
|
||||
>
|
||||
<SchedulesProvider query={undefined}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||
}}
|
||||
ref={table}
|
||||
>
|
||||
{!isNarrowWidth ? (
|
||||
<SplitsExpandedProvider initialMode="collapse">
|
||||
<TransactionList
|
||||
headerContent={undefined}
|
||||
tableRef={table}
|
||||
account={undefined}
|
||||
transactions={transactionsGrouped}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
category={undefined}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={null}
|
||||
showBalances={false}
|
||||
showReconciled={true}
|
||||
showCleared={false}
|
||||
showAccount={true}
|
||||
isAdding={false}
|
||||
isNew={() => false}
|
||||
isMatched={() => false}
|
||||
isFiltered={() => true}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={false}
|
||||
addNotification={addNotification}
|
||||
renderEmpty={() => (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
)}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
onChange={() => {}}
|
||||
onRefetch={() => setDirty(true)}
|
||||
onCloseAddTransaction={() => {}}
|
||||
onCreatePayee={() => {}}
|
||||
onApplyFilter={() => {}}
|
||||
onBatchDelete={() => {}}
|
||||
onBatchDuplicate={() => {}}
|
||||
onBatchLinkSchedule={() => {}}
|
||||
onBatchUnlinkSchedule={() => {}}
|
||||
onCreateRule={() => {}}
|
||||
onScheduleAction={() => {}}
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
y,
|
||||
touchAction: 'pan-x',
|
||||
backgroundColor: theme.mobileNavBackground,
|
||||
borderTop: `1px solid ${theme.menuBorder}`,
|
||||
...styles.shadow,
|
||||
height: totalHeight + CHEVRON_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
!mobileTransactionsOpen
|
||||
? open({ canceled: false })
|
||||
: close()
|
||||
}
|
||||
className={css({
|
||||
color: theme.pageTextSubdued,
|
||||
height: 42,
|
||||
'&[data-pressed]': { backgroundColor: 'transparent' },
|
||||
})}
|
||||
>
|
||||
{!mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronUp width={16} height={16} />
|
||||
<Trans>Show transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
{mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronDown width={16} height={16} />
|
||||
<Trans>Hide transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<View
|
||||
style={{ height: '100%', width: '100%', overflow: 'auto' }}
|
||||
>
|
||||
<TransactionListMobile
|
||||
isLoading={false}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
transactions={allTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
)}
|
||||
</View>
|
||||
</SchedulesProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarWithHeaderProps = {
|
||||
calendar: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
data: CalendarDataType[];
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
};
|
||||
onApplyFilter: (
|
||||
conditions:
|
||||
| null
|
||||
| RuleConditionEntity
|
||||
| {
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
id: RuleConditionEntity[];
|
||||
},
|
||||
) => void;
|
||||
firstDayOfWeekIdx: string;
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
};
|
||||
|
||||
function CalendarWithHeader({
|
||||
calendar,
|
||||
onApplyFilter,
|
||||
firstDayOfWeekIdx,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
}: CalendarWithHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
minWidth: '300px',
|
||||
maxWidth: '300px',
|
||||
padding: 10,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
onClick={() =>
|
||||
onApplyFilter({
|
||||
conditions: [...conditions.filter(f => f.field !== 'date')],
|
||||
conditionsOp,
|
||||
id: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'inline-block',
|
||||
width: 'max-content',
|
||||
}}
|
||||
onPress={() => {
|
||||
onApplyFilter({
|
||||
conditions: [
|
||||
...conditions.filter(f => f.field !== 'date'),
|
||||
{
|
||||
field: 'date',
|
||||
op: 'is',
|
||||
value: format(calendar.start, 'yyyy-MM'),
|
||||
options: {
|
||||
month: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
conditionsOp: 'and',
|
||||
id: [],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{format(calendar.start, 'MMMM yyyy')}
|
||||
</Button>
|
||||
<View
|
||||
style={{ display: 'grid', gridTemplateColumns: '16px 1fr', gap: 2 }}
|
||||
>
|
||||
<SvgArrowThickUp
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ color: chartTheme.colors.blue, flexShrink: 0 }}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.blue,
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
aria-label={t('Income')}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(calendar.totalIncome)}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
<SvgArrowThickDown
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ color: chartTheme.colors.red, flexShrink: 0 }}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.red,
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
aria-label={t('Expenses')}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(calendar.totalExpense)}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flexGrow: 1, display: 'block', marginBottom: 20 }}>
|
||||
<CalendarGraph
|
||||
data={calendar.data}
|
||||
start={calendar.start}
|
||||
onDayClick={date => {
|
||||
if (date) {
|
||||
onApplyFilter({
|
||||
conditions: [
|
||||
...conditions.filter(f => f.field !== 'date'),
|
||||
{
|
||||
field: 'date',
|
||||
op: 'is',
|
||||
value: format(date, 'yyyy-MM-dd'),
|
||||
},
|
||||
],
|
||||
conditionsOp: 'and',
|
||||
id: [],
|
||||
});
|
||||
} else {
|
||||
onApplyFilter({
|
||||
conditions: [...conditions.filter(f => f.field !== 'date')],
|
||||
conditionsOp: 'and',
|
||||
id: [],
|
||||
});
|
||||
}
|
||||
}}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarCardHeaderProps = {
|
||||
start: string;
|
||||
end: string;
|
||||
totalIncome: number;
|
||||
totalExpense: number;
|
||||
isNarrowWidth: boolean;
|
||||
};
|
||||
|
||||
function CalendarCardHeader({
|
||||
start,
|
||||
end,
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
isNarrowWidth,
|
||||
}: CalendarCardHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...styles.smallText,
|
||||
marginLeft: isNarrowWidth ? 0 : 16,
|
||||
marginTop: isNarrowWidth ? 16 : 0,
|
||||
justifyContent: isNarrowWidth ? 'center' : 'flex-end',
|
||||
flexDirection: 'row',
|
||||
height: '100px',
|
||||
minWidth: '210px',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: '200px',
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<DateRange start={start} end={end} />
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr',
|
||||
gridAutoRows: '1fr',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Income</Trans>:
|
||||
</View>
|
||||
<View style={{ color: chartTheme.colors.blue }}>
|
||||
<PrivacyFilter>{amountToCurrency(totalIncome)}</PrivacyFilter>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Expenses</Trans>:
|
||||
</View>
|
||||
<View style={{ color: chartTheme.colors.red }}>
|
||||
<PrivacyFilter>{amountToCurrency(totalExpense)}</PrivacyFilter>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getField(field?: string) {
|
||||
if (!field) {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case 'account':
|
||||
return 'account.name';
|
||||
case 'payee':
|
||||
return 'payee.name';
|
||||
case 'category':
|
||||
return 'category.name';
|
||||
case 'payment':
|
||||
return 'amount';
|
||||
case 'deposit':
|
||||
return 'amount';
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Ref,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { debounce } from 'debounce';
|
||||
|
||||
import { amountToCurrency } from 'loot-core/shared/util';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { type CalendarWidget } from 'loot-core/types/models';
|
||||
import { type SyncedPrefs } from 'loot-core/types/prefs';
|
||||
|
||||
import { useMergedRefs } from '../../../hooks/useMergedRefs';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { useResizeObserver } from '../../../hooks/useResizeObserver';
|
||||
import { SvgArrowThickDown, SvgArrowThickUp } from '../../../icons/v1';
|
||||
import { styles, theme } from '../../../style';
|
||||
import { Block } from '../../common/Block';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Tooltip } from '../../common/Tooltip';
|
||||
import { View } from '../../common/View';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
import { useResponsive } from '../../responsive/ResponsiveProvider';
|
||||
import { chartTheme } from '../chart-theme';
|
||||
import { DateRange } from '../DateRange';
|
||||
import { CalendarGraph } from '../graphs/CalendarGraph';
|
||||
import { LoadingIndicator } from '../LoadingIndicator';
|
||||
import { ReportCard } from '../ReportCard';
|
||||
import { ReportCardName } from '../ReportCardName';
|
||||
import { calculateTimeRange } from '../reportRanges';
|
||||
import {
|
||||
type CalendarDataType,
|
||||
calendarSpreadsheet,
|
||||
} from '../spreadsheets/calendar-spreadsheet';
|
||||
import { useReport } from '../useReport';
|
||||
|
||||
type CalendarCardProps = {
|
||||
widgetId: string;
|
||||
isEditing?: boolean;
|
||||
meta?: CalendarWidget['meta'];
|
||||
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
||||
};
|
||||
|
||||
export function CalendarCard({
|
||||
widgetId,
|
||||
isEditing,
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
firstDayOfWeekIdx,
|
||||
}: CalendarCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, {
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
});
|
||||
const params = useMemo(
|
||||
() =>
|
||||
calendarSpreadsheet(
|
||||
start,
|
||||
end,
|
||||
meta?.conditions,
|
||||
meta?.conditionsOp,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
[start, end, meta?.conditions, meta?.conditionsOp, firstDayOfWeekIdx],
|
||||
);
|
||||
|
||||
const [cardOrientation, setCardOrientation] = useState<'row' | 'column'>(
|
||||
'row',
|
||||
);
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const cardRef = useResizeObserver(rect => {
|
||||
if (rect.height > rect.width) {
|
||||
setCardOrientation('column');
|
||||
} else {
|
||||
setCardOrientation('row');
|
||||
}
|
||||
});
|
||||
|
||||
const data = useReport('calendar', params);
|
||||
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const { totalIncome, totalExpense } = useMemo(() => {
|
||||
if (!data) {
|
||||
return { totalIncome: 0, totalExpense: 0 };
|
||||
}
|
||||
return {
|
||||
totalIncome: data.calendarData.reduce(
|
||||
(prev, cur) => prev + cur.totalIncome,
|
||||
0,
|
||||
),
|
||||
totalExpense: data.calendarData.reduce(
|
||||
(prev, cur) => prev + cur.totalExpense,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const [monthNameFormats, setMonthNameFormats] = useState<string[]>([]);
|
||||
const [selectedMonthNameFormat, setSelectedMonthNameFormat] =
|
||||
useState<string>('MMMM yyyy');
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setMonthNameFormats(
|
||||
Array(data.calendarData.length).map(() => 'MMMM yyyy'),
|
||||
);
|
||||
} else {
|
||||
setMonthNameFormats([]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (monthNameFormats.length) {
|
||||
setSelectedMonthNameFormat(
|
||||
monthNameFormats.reduce(
|
||||
(a, b) => ((a?.length ?? 0) <= (b?.length ?? 0) ? a : b),
|
||||
'MMMM yyyy',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedMonthNameFormat('MMMM yyyy');
|
||||
}
|
||||
}, [monthNameFormats]);
|
||||
|
||||
const calendarLenSize = useMemo(() => {
|
||||
if (!data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data?.calendarData.length;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
to={`/reports/calendar/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
text: t('Rename'),
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
break;
|
||||
case 'remove':
|
||||
onRemove();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized selection: ${item}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View
|
||||
ref={el => el && cardRef(el)}
|
||||
style={{ flex: 1, margin: 2, overflow: 'hidden', width: '100%' }}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', padding: 20, paddingBottom: 0 }}>
|
||||
<View style={{ flex: 1, marginBottom: -5 }}>
|
||||
<ReportCardName
|
||||
name={meta?.name || t('Calendar')}
|
||||
isEditing={nameMenuOpen}
|
||||
onChange={newName => {
|
||||
onMetaChange({
|
||||
...meta,
|
||||
name: newName,
|
||||
});
|
||||
setNameMenuOpen(false);
|
||||
}}
|
||||
onClose={() => setNameMenuOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Block
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr',
|
||||
gridAutoRows: '1fr',
|
||||
}}
|
||||
>
|
||||
{totalIncome !== 0 && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Income:</Trans>
|
||||
</View>
|
||||
<View style={{ color: chartTheme.colors.blue }}>
|
||||
{totalIncome !== 0 ? (
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(totalIncome)}
|
||||
</PrivacyFilter>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{totalExpense !== 0 && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Expenses:</Trans>
|
||||
</View>
|
||||
<View style={{ color: chartTheme.colors.red }}>
|
||||
{totalExpense !== 0 ? (
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(totalExpense)}
|
||||
</PrivacyFilter>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<DateRange start={start} end={end} />
|
||||
</Tooltip>
|
||||
</Block>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
margin: 6,
|
||||
overflowX:
|
||||
cardOrientation === 'row'
|
||||
? isNarrowWidth
|
||||
? 'auto'
|
||||
: calendarLenSize > 4
|
||||
? 'auto'
|
||||
: 'hidden'
|
||||
: 'hidden',
|
||||
...styles.horizontalScrollbar,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: cardOrientation,
|
||||
gap: 16,
|
||||
marginTop: 10,
|
||||
textAlign: 'left',
|
||||
marginBottom: isNarrowWidth ? 4 : 0,
|
||||
width:
|
||||
cardOrientation === 'row'
|
||||
? isNarrowWidth
|
||||
? `${calendarLenSize * 100}%`
|
||||
: calendarLenSize > 4
|
||||
? `${100 + ((calendarLenSize - 4) % 4) * 25}%`
|
||||
: 'auto'
|
||||
: 'auto',
|
||||
}}
|
||||
>
|
||||
{data ? (
|
||||
data.calendarData.map((calendar, index) => (
|
||||
<CalendarCardInner
|
||||
key={index}
|
||||
calendar={calendar}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx ?? '0'}
|
||||
setMonthNameFormats={setMonthNameFormats}
|
||||
selectedMonthNameFormat={selectedMonthNameFormat}
|
||||
index={index}
|
||||
widgetId={widgetId}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarCardInnerProps = {
|
||||
calendar: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
data: CalendarDataType[];
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
};
|
||||
firstDayOfWeekIdx: string;
|
||||
setMonthNameFormats: Dispatch<SetStateAction<string[]>>;
|
||||
selectedMonthNameFormat: string;
|
||||
index: number;
|
||||
widgetId: string;
|
||||
};
|
||||
function CalendarCardInner({
|
||||
calendar,
|
||||
firstDayOfWeekIdx,
|
||||
setMonthNameFormats,
|
||||
selectedMonthNameFormat,
|
||||
index,
|
||||
widgetId,
|
||||
}: CalendarCardInnerProps) {
|
||||
const [monthNameVisible, setMonthNameVisible] = useState(true);
|
||||
const monthFormatSizeContainers = useRef<(HTMLSpanElement | null)[]>(
|
||||
new Array(5),
|
||||
);
|
||||
const monthNameContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const measureMonthFormats = useCallback(() => {
|
||||
const measurements = monthFormatSizeContainers.current.map(container => ({
|
||||
width: container?.clientWidth ?? 0,
|
||||
format: container?.getAttribute('data-format') ?? '',
|
||||
}));
|
||||
return measurements;
|
||||
}, []);
|
||||
|
||||
const debouncedResizeCallback = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const measurements = measureMonthFormats();
|
||||
const containerWidth = monthNameContainerRef.current?.clientWidth ?? 0;
|
||||
|
||||
const suitableFormat = measurements.find(m => containerWidth > m.width);
|
||||
if (suitableFormat) {
|
||||
if (
|
||||
monthNameContainerRef.current &&
|
||||
containerWidth > suitableFormat.width
|
||||
) {
|
||||
setMonthNameFormats(prev => {
|
||||
const newArray = [...prev];
|
||||
newArray[index] = suitableFormat.format;
|
||||
return newArray;
|
||||
});
|
||||
|
||||
setMonthNameVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
monthNameContainerRef.current &&
|
||||
monthNameContainerRef.current.scrollWidth >
|
||||
monthNameContainerRef.current.clientWidth
|
||||
) {
|
||||
setMonthNameVisible(false);
|
||||
} else {
|
||||
setMonthNameVisible(true);
|
||||
}
|
||||
}, 20),
|
||||
[measureMonthFormats, monthNameContainerRef, index, setMonthNameFormats],
|
||||
);
|
||||
|
||||
const monthNameResizeRef = useResizeObserver(debouncedResizeCallback);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedResizeCallback?.clear();
|
||||
};
|
||||
}, [debouncedResizeCallback]);
|
||||
|
||||
const mergedRef = useMergedRefs(
|
||||
monthNameContainerRef,
|
||||
monthNameResizeRef,
|
||||
) as Ref<HTMLDivElement>;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const monthFormats = [
|
||||
{ format: 'MMMM yyyy', text: format(calendar.start, 'MMMM yyyy') },
|
||||
{ format: 'MMM yyyy', text: format(calendar.start, 'MMM yyyy') },
|
||||
{ format: 'MMM yy', text: format(calendar.start, 'MMM yy') },
|
||||
{ format: 'MMM', text: format(calendar.start, 'MMM') },
|
||||
{ format: '', text: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, overflow: 'visible' }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
marginLeft: 5,
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
ref={mergedRef}
|
||||
style={{
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{
|
||||
visibility: monthNameVisible ? 'visible' : 'hidden',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
width: 'max-content',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 'bold',
|
||||
fontSize: '12px',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
onPress={() => {
|
||||
navigate(
|
||||
`/reports/calendar/${widgetId}?month=${format(calendar.start, 'yyyy-MM')}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{selectedMonthNameFormat &&
|
||||
format(calendar.start, selectedMonthNameFormat)}
|
||||
</Button>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.blue,
|
||||
flexDirection: 'row',
|
||||
fontSize: '10px',
|
||||
marginRight: 10,
|
||||
}}
|
||||
aria-label="Income"
|
||||
>
|
||||
{calendar.totalIncome !== 0 ? (
|
||||
<>
|
||||
<SvgArrowThickUp
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(calendar.totalIncome)}
|
||||
</PrivacyFilter>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
color: chartTheme.colors.red,
|
||||
flexDirection: 'row',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
aria-label="Expenses"
|
||||
>
|
||||
{calendar.totalExpense !== 0 ? (
|
||||
<>
|
||||
<SvgArrowThickDown
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(calendar.totalExpense)}
|
||||
</PrivacyFilter>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<CalendarGraph
|
||||
data={calendar.data}
|
||||
start={calendar.start}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
onDayClick={date => {
|
||||
if (date) {
|
||||
navigate(
|
||||
`/reports/calendar/${widgetId}?day=${format(date, 'yyyy-MM-dd')}`,
|
||||
);
|
||||
} else {
|
||||
navigate(`/reports/calendar/${widgetId}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View style={{ fontWeight: 'bold', fontSize: '12px' }}>
|
||||
{monthFormats.map((item, idx) => (
|
||||
<span
|
||||
key={item.format}
|
||||
ref={node => {
|
||||
if (node) monthFormatSizeContainers.current[idx] = node;
|
||||
}}
|
||||
style={{ position: 'fixed', top: -9999, left: -9999 }}
|
||||
data-format={item.format}
|
||||
>
|
||||
{item.text}
|
||||
{item.text && ':'}
|
||||
</span>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as d from 'date-fns';
|
||||
|
||||
import { runQuery } from 'loot-core/src/client/query-helpers';
|
||||
import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { q } from 'loot-core/src/shared/query';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models';
|
||||
import { type SyncedPrefs } from 'loot-core/types/prefs';
|
||||
|
||||
export type CalendarDataType = {
|
||||
date: Date;
|
||||
incomeValue: number;
|
||||
expenseValue: number;
|
||||
incomeSize: number;
|
||||
expenseSize: number;
|
||||
};
|
||||
export function calendarSpreadsheet(
|
||||
start: string,
|
||||
end: string,
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or' = 'and',
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'],
|
||||
) {
|
||||
return async (
|
||||
spreadsheet: ReturnType<typeof useSpreadsheet>,
|
||||
setData: (data: {
|
||||
calendarData: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
data: CalendarDataType[];
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
}[];
|
||||
}) => void,
|
||||
) => {
|
||||
let filters;
|
||||
|
||||
try {
|
||||
const { filters: filtersLocal } = await send(
|
||||
'make-filters-from-conditions',
|
||||
{
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
},
|
||||
);
|
||||
filters = filtersLocal;
|
||||
} catch (error) {
|
||||
console.error('Failed to make filters from conditions:', error);
|
||||
filters = [];
|
||||
}
|
||||
const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
|
||||
|
||||
let startDay: Date;
|
||||
try {
|
||||
startDay = d.parse(
|
||||
monthUtils.firstDayOfMonth(start),
|
||||
'yyyy-MM-dd',
|
||||
new Date(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse start date:', error);
|
||||
throw new Error('Invalid start date format');
|
||||
}
|
||||
|
||||
let endDay: Date;
|
||||
try {
|
||||
endDay = d.parse(
|
||||
monthUtils.lastDayOfMonth(end),
|
||||
'yyyy-MM-dd',
|
||||
new Date(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse end date:', error);
|
||||
throw new Error('Invalid end date format');
|
||||
}
|
||||
|
||||
const makeRootQuery = () =>
|
||||
q('transactions')
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: d.format(startDay, 'yyyy-MM-dd') } },
|
||||
{ date: { $lte: d.format(endDay, 'yyyy-MM-dd') } },
|
||||
],
|
||||
})
|
||||
.filter({
|
||||
[conditionsOpKey]: filters,
|
||||
})
|
||||
.groupBy(['date'])
|
||||
.select(['date', { amount: { $sum: '$amount' } }]);
|
||||
|
||||
let expenseData;
|
||||
try {
|
||||
expenseData = await runQuery(
|
||||
makeRootQuery().filter({
|
||||
$and: { amount: { $lt: 0 } },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch expense data:', error);
|
||||
expenseData = { data: [] };
|
||||
}
|
||||
|
||||
let incomeData;
|
||||
try {
|
||||
incomeData = await runQuery(
|
||||
makeRootQuery().filter({
|
||||
$and: { amount: { $gt: 0 } },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch income data:', error);
|
||||
incomeData = { data: [] };
|
||||
}
|
||||
|
||||
const getOneDatePerMonth = (start: Date, end: Date) => {
|
||||
const months = [];
|
||||
let currentDate = d.startOfMonth(start);
|
||||
|
||||
while (!d.isSameMonth(currentDate, end)) {
|
||||
months.push(currentDate);
|
||||
currentDate = d.addMonths(currentDate, 1);
|
||||
}
|
||||
months.push(end);
|
||||
|
||||
return months;
|
||||
};
|
||||
|
||||
setData(
|
||||
recalculate(
|
||||
incomeData.data,
|
||||
expenseData.data,
|
||||
getOneDatePerMonth(startDay, endDay),
|
||||
start,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function recalculate(
|
||||
incomeData: Array<{
|
||||
date: string;
|
||||
amount: number;
|
||||
}>,
|
||||
expenseData: Array<{
|
||||
date: string;
|
||||
amount: number;
|
||||
}>,
|
||||
months: Date[],
|
||||
start: string,
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'],
|
||||
) {
|
||||
const incomeDataMap = new Map<string, number>();
|
||||
incomeData.forEach(item => {
|
||||
incomeDataMap.set(item.date, item.amount);
|
||||
});
|
||||
|
||||
const expenseDataMap = new Map<string, number>();
|
||||
expenseData.forEach(item => {
|
||||
expenseDataMap.set(item.date, item.amount);
|
||||
});
|
||||
|
||||
const parseAndCacheDate = (() => {
|
||||
const cache = new Map<string, Date>();
|
||||
return (dateStr: string) => {
|
||||
if (!cache.has(dateStr)) {
|
||||
cache.set(dateStr, d.parse(dateStr, 'yyyy-MM-dd', new Date()));
|
||||
}
|
||||
return cache.get(dateStr)!;
|
||||
};
|
||||
})();
|
||||
|
||||
const getDaysArray = (month: Date) => {
|
||||
const expenseValues = expenseData
|
||||
.filter(f => d.isSameMonth(parseAndCacheDate(f.date), month))
|
||||
.map(m => Math.abs(m.amount));
|
||||
const incomeValues = incomeData
|
||||
.filter(f => d.isSameMonth(parseAndCacheDate(f.date), month))
|
||||
.map(m => Math.abs(m.amount));
|
||||
|
||||
const totalExpenseValue = expenseValues.length
|
||||
? expenseValues.reduce((acc, val) => acc + val, 0)
|
||||
: null;
|
||||
|
||||
const totalIncomeValue = incomeValues.length
|
||||
? incomeValues.reduce((acc, val) => acc + val, 0)
|
||||
: null;
|
||||
|
||||
const getBarLength = (value: number) => {
|
||||
if (
|
||||
value < 0 &&
|
||||
typeof totalExpenseValue === 'number' &&
|
||||
totalExpenseValue > 0
|
||||
) {
|
||||
const result = (Math.abs(value) / totalExpenseValue) * 100;
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
} else if (
|
||||
value > 0 &&
|
||||
typeof totalIncomeValue === 'number' &&
|
||||
totalIncomeValue > 0
|
||||
) {
|
||||
const result = (value / totalIncomeValue) * 100;
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const firstDay = d.startOfMonth(month);
|
||||
const beginDay = d.startOfWeek(firstDay, {
|
||||
weekStartsOn:
|
||||
firstDayOfWeekIdx !== undefined &&
|
||||
!Number.isNaN(parseInt(firstDayOfWeekIdx)) &&
|
||||
parseInt(firstDayOfWeekIdx) >= 0 &&
|
||||
parseInt(firstDayOfWeekIdx) <= 6
|
||||
? (parseInt(firstDayOfWeekIdx) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: 0,
|
||||
});
|
||||
let totalDays =
|
||||
d.differenceInDays(firstDay, beginDay) + d.getDaysInMonth(firstDay);
|
||||
if (totalDays % 7 !== 0) {
|
||||
totalDays += 7 - (totalDays % 7);
|
||||
}
|
||||
const daysArray = [];
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
const currentDate = d.addDays(beginDay, i);
|
||||
if (!d.isSameMonth(currentDate, firstDay)) {
|
||||
daysArray.push({
|
||||
date: currentDate,
|
||||
incomeValue: 0,
|
||||
expenseValue: 0,
|
||||
incomeSize: 0,
|
||||
expenseSize: 0,
|
||||
});
|
||||
} else {
|
||||
const dateKey = d.format(currentDate, 'yyyy-MM-dd');
|
||||
const currentIncome = incomeDataMap.get(dateKey) ?? 0;
|
||||
const currentExpense = expenseDataMap.get(dateKey) ?? 0;
|
||||
|
||||
daysArray.push({
|
||||
date: currentDate,
|
||||
incomeSize: getBarLength(currentIncome),
|
||||
incomeValue: Math.abs(currentIncome) / 100,
|
||||
expenseSize: getBarLength(currentExpense),
|
||||
expenseValue: Math.abs(currentExpense) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: daysArray as CalendarDataType[],
|
||||
totalExpense: (totalExpenseValue ?? 0) / 100,
|
||||
totalIncome: (totalIncomeValue ?? 0) / 100,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
calendarData: months.map(m => {
|
||||
return {
|
||||
...getDaysArray(m),
|
||||
start: d.startOfMonth(m),
|
||||
end: d.endOfMonth(m),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export function TransactionList({
|
||||
onCloseAddTransaction,
|
||||
onCreatePayee,
|
||||
onApplyFilter,
|
||||
showSelection = true,
|
||||
allowSplitTransaction = true,
|
||||
onBatchDelete,
|
||||
onBatchDuplicate,
|
||||
onBatchLinkSchedule,
|
||||
@@ -251,6 +253,8 @@ export function TransactionList({
|
||||
onCreateRule={onCreateRule}
|
||||
onScheduleAction={onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
showSelection={showSelection}
|
||||
allowSplitTransaction={allowSplitTransaction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ const TransactionHeader = memo(
|
||||
onSort,
|
||||
ascDesc,
|
||||
field,
|
||||
showSelection,
|
||||
}) => {
|
||||
const dispatchSelected = useSelectedDispatch();
|
||||
const { t } = useTranslation();
|
||||
@@ -202,19 +203,32 @@ const TransactionHeader = memo(
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<SelectCell
|
||||
exposed={true}
|
||||
focused={false}
|
||||
selected={hasSelected}
|
||||
width={20}
|
||||
style={{
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
onSelect={e =>
|
||||
dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
|
||||
}
|
||||
/>
|
||||
{showSelection && (
|
||||
<SelectCell
|
||||
exposed={true}
|
||||
focused={false}
|
||||
selected={hasSelected}
|
||||
width={20}
|
||||
style={{
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
onSelect={e =>
|
||||
dispatchSelected({
|
||||
type: 'select-all',
|
||||
isRangeSelect: e.shiftKey,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!showSelection && (
|
||||
<Field
|
||||
style={{
|
||||
width: '20px',
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HeaderCell
|
||||
value={t('Date')}
|
||||
width={110}
|
||||
@@ -866,6 +880,8 @@ const Transaction = memo(function Transaction({
|
||||
onNotesTagClick,
|
||||
splitError,
|
||||
listContainerRef,
|
||||
showSelection,
|
||||
allowSplitTransaction,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const dispatchSelected = useSelectedDispatch();
|
||||
@@ -1157,7 +1173,7 @@ const Transaction = memo(function Transaction({
|
||||
) : (
|
||||
<Cell width={20} />
|
||||
)
|
||||
) : isPreview && isChild ? (
|
||||
) : (isPreview && isChild) || !showSelection ? (
|
||||
<Cell width={20} />
|
||||
) : (
|
||||
<SelectCell
|
||||
@@ -1491,7 +1507,7 @@ const Transaction = memo(function Transaction({
|
||||
value={categoryId}
|
||||
focused={true}
|
||||
clearOnBlur={false}
|
||||
showSplitOption={!isChild && !isParent}
|
||||
showSplitOption={!isChild && !isParent && allowSplitTransaction}
|
||||
shouldSaveFromKey={shouldSaveFromKey}
|
||||
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
|
||||
onUpdate={onUpdate}
|
||||
@@ -1763,6 +1779,8 @@ function NewTransaction({
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNotesTagClick={onNotesTagClick}
|
||||
balance={balance}
|
||||
showSelection={true}
|
||||
allowSplitTransaction={true}
|
||||
/>
|
||||
))}
|
||||
<View
|
||||
@@ -1883,6 +1901,8 @@ function TransactionTableInner({
|
||||
isNew,
|
||||
isMatched,
|
||||
isExpanded,
|
||||
showSelection,
|
||||
allowSplitTransaction,
|
||||
} = props;
|
||||
|
||||
const trans = item;
|
||||
@@ -1965,6 +1985,8 @@ function TransactionTableInner({
|
||||
)
|
||||
}
|
||||
listContainerRef={listContainerRef}
|
||||
showSelection={showSelection}
|
||||
allowSplitTransaction={allowSplitTransaction}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1989,6 +2011,7 @@ function TransactionTableInner({
|
||||
onSort={props.onSort}
|
||||
ascDesc={props.ascDesc}
|
||||
field={props.sortField}
|
||||
showSelection={props.showSelection}
|
||||
/>
|
||||
|
||||
{props.isAdding && (
|
||||
@@ -2604,6 +2627,8 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
newTransactions={newTransactions}
|
||||
tableNavigator={tableNavigator}
|
||||
newNavigator={newNavigator}
|
||||
showSelection={props.showSelection}
|
||||
allowSplitTransaction={props.allowSplitTransaction}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,8 @@ function LiveTransactionTable(props) {
|
||||
onAdd={onAdd}
|
||||
onAddSplit={onAddSplit}
|
||||
onCreatePayee={onCreatePayee}
|
||||
showSelection={true}
|
||||
allowSplitTransaction={true}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
|
||||
@@ -213,3 +213,5 @@ export const floatingActionBarText = colorPalette.navy150;
|
||||
export const tooltipText = colorPalette.navy100;
|
||||
export const tooltipBackground = colorPalette.navy800;
|
||||
export const tooltipBorder = colorPalette.navy700;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy900;
|
||||
|
||||
@@ -213,3 +213,5 @@ export const floatingActionBarText = colorPalette.navy50;
|
||||
export const tooltipText = colorPalette.navy900;
|
||||
export const tooltipBackground = colorPalette.navy50;
|
||||
export const tooltipBorder = colorPalette.navy150;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy900;
|
||||
|
||||
@@ -215,3 +215,5 @@ export const floatingActionBarText = colorPalette.navy50;
|
||||
export const tooltipText = colorPalette.navy900;
|
||||
export const tooltipBackground = colorPalette.white;
|
||||
export const tooltipBorder = colorPalette.navy150;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy100;
|
||||
|
||||
@@ -215,3 +215,5 @@ export const floatingActionBarText = colorPalette.purple200;
|
||||
export const tooltipText = colorPalette.gray100;
|
||||
export const tooltipBackground = colorPalette.gray800;
|
||||
export const tooltipBorder = colorPalette.gray600;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy900;
|
||||
|
||||
@@ -82,6 +82,7 @@ const exportModel = {
|
||||
'custom-report',
|
||||
'markdown-card',
|
||||
'summary-card',
|
||||
'calendar-card',
|
||||
].includes(widget.type)
|
||||
) {
|
||||
throw new ValidationError(
|
||||
|
||||
@@ -66,7 +66,8 @@ type SpecializedWidget =
|
||||
| CashFlowWidget
|
||||
| SpendingWidget
|
||||
| MarkdownWidget
|
||||
| SummaryWidget;
|
||||
| SummaryWidget
|
||||
| CalendarWidget;
|
||||
export type Widget = SpecializedWidget | CustomReportWidget;
|
||||
export type NewWidget = Omit<Widget, 'id' | 'tombstone'>;
|
||||
|
||||
@@ -115,3 +116,13 @@ export type PercentageSummaryContent = {
|
||||
};
|
||||
|
||||
export type SummaryContent = BaseSummaryContent | PercentageSummaryContent;
|
||||
|
||||
export type CalendarWidget = AbstractWidget<
|
||||
'calendar-card',
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
timeFrame?: TimeFrame;
|
||||
} | null
|
||||
>;
|
||||
|
||||
6
upcoming-release-notes/3828.md
Normal file
6
upcoming-release-notes/3828.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Added Calendar report
|
||||
Reference in New Issue
Block a user