Skip to content

Commit

Permalink
Calendar Report (actualbudget#3828)
Browse files Browse the repository at this point in the history
  • Loading branch information
lelemm authored Dec 14, 2024
1 parent ef95850 commit ec977ee
Show file tree
Hide file tree
Showing 17 changed files with 2,185 additions and 17 deletions.
17 changes: 17 additions & 0 deletions packages/desktop-client/src/components/reports/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit ec977ee

Please sign in to comment.