Calendar Report (#3828)

This commit is contained in:
lelemm
2024-12-14 17:19:14 -03:00
committed by GitHub
parent ef95850e93
commit ec977ee51a
17 changed files with 2185 additions and 17 deletions

View File

@@ -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>
))}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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),
};
}),
};
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
});

View File

@@ -168,6 +168,8 @@ function LiveTransactionTable(props) {
onAdd={onAdd}
onAddSplit={onAddSplit}
onCreatePayee={onCreatePayee}
showSelection={true}
allowSplitTransaction={true}
/>
</SplitsExpandedProvider>
</SelectedProviderWithItems>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -82,6 +82,7 @@ const exportModel = {
'custom-report',
'markdown-card',
'summary-card',
'calendar-card',
].includes(widget.type)
) {
throw new ValidationError(

View File

@@ -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
>;

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [lelemm]
---
Added Calendar report