Skip to content

Commit

Permalink
feat(ui): Next funding contribution is based on forecasting.
Browse files Browse the repository at this point in the history
The next contribution amount shown in the funding schedules view is now
based on the forecasting endpoint. This means that the contribution is
based on both the amount needed for expenses now, as well as the amount
spent from expenses between now and the next funding event. This should
provide a (generally) more accurate prediction on next contribution.
  • Loading branch information
elliotcourant committed Nov 26, 2022
1 parent bae905d commit 5c918c7
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 17 deletions.
52 changes: 52 additions & 0 deletions pkg/controller/forecast.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

func (c *Controller) handleForecasting(p iris.Party) {
p.Post("/{bankAccountId:uint64}/forecast/spending", c.postForecastNewSpending)
p.Post("/{bankAccountId:uint64}/forecast/next_funding", c.postForecastNextFunding)
}

func (c *Controller) postForecastNewSpending(ctx iris.Context) {
Expand Down Expand Up @@ -84,3 +85,54 @@ func (c *Controller) postForecastNewSpending(ctx iris.Context) {
),
})
}

func (c *Controller) postForecastNextFunding(ctx iris.Context) {
var request struct {
FundingScheduleId uint64 `json:"fundingScheduleId"`
}
if err := ctx.ReadJSON(&request); err != nil {
c.invalidJson(ctx)
return
}

if request.FundingScheduleId == 0 {
c.badRequest(ctx, "Funding schedule must be specified")
return
}

bankAccountId := ctx.Params().GetUint64Default("bankAccountId", 0)
if bankAccountId == 0 {
c.badRequest(ctx, "Must specify a valid bank account Id")
return
}

repo := c.mustGetAuthenticatedRepository(ctx)

fundingSchedule, err := repo.GetFundingSchedule(c.getContext(ctx), bankAccountId, request.FundingScheduleId)
if err != nil {
c.wrapPgError(ctx, err, "could not retrieve funding schedule")
return
}

spending, err := repo.GetSpendingByFundingSchedule(c.getContext(ctx), bankAccountId, request.FundingScheduleId)
if err != nil {
c.wrapPgError(ctx, err, "could not retrieve spending for forecast")
return
}

fundingForecast := forecast.NewForecaster(
spending,
[]models.FundingSchedule{
*fundingSchedule,
},
)
timezone := c.mustGetTimezone(ctx)
ctx.JSON(map[string]interface{}{
"nextContribution": fundingForecast.GetNextContribution(
c.getContext(ctx),
time.Now(),
request.FundingScheduleId,
timezone,
),
})
}
38 changes: 32 additions & 6 deletions pkg/forecast/forecast.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Forecast struct {
type Forecaster interface {
GetForecast(ctx context.Context, start, end time.Time, timezone *time.Location) Forecast
GetAverageContribution(ctx context.Context, start, end time.Time, timezone *time.Location) int64
GetNextContribution(ctx context.Context, start time.Time, fundingScheduleId uint64, timezone *time.Location) int64
}

type forecasterBase struct {
Expand Down Expand Up @@ -61,9 +62,9 @@ func (f *forecasterBase) GetForecast(ctx context.Context, start, end time.Time,
span := crumbs.StartFnTrace(ctx)
defer span.Finish()
span.Data = map[string]interface{}{
"start": start,
"end": end,
"timezone": timezone.String(),
"start": start,
"end": end,
"timezone": timezone.String(),
}
forecast := Forecast{
StartingBalance: f.currentBalance,
Expand Down Expand Up @@ -151,9 +152,9 @@ func (f *forecasterBase) GetAverageContribution(ctx context.Context, start, end
span := crumbs.StartFnTrace(ctx)
defer span.Finish()
span.Data = map[string]interface{}{
"start": start,
"end": end,
"timezone": timezone.String(),
"start": start,
"end": end,
"timezone": timezone.String(),
}
forecast := f.GetForecast(span.Context(), start, end, timezone)
contributionAmounts := map[int64]int64{}
Expand All @@ -174,3 +175,28 @@ func (f *forecasterBase) GetAverageContribution(ctx context.Context, start, end

return popularContribution
}

func (f *forecasterBase) GetNextContribution(ctx context.Context, start time.Time, fundingScheduleId uint64, timezone *time.Location) int64 {
span := crumbs.StartFnTrace(ctx)
defer span.Finish()

end := f.funding[fundingScheduleId].GetNextFundingEventAfter(span.Context(), start, timezone)

span.Data = map[string]interface{}{
"start": start,
"end": end,
"fundingScheduleId": fundingScheduleId,
"timezone": timezone.String(),
}

forecast := f.GetForecast(span.Context(), start, end.Date.AddDate(0, 0, 1), timezone)
for _, event := range forecast.Events {
if len(event.Funding) == 0 {
continue
}

return event.Contribution
}

return 0
}
4 changes: 2 additions & 2 deletions ui/components/Expenses/ExpensesView/ExpensesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SpendingType } from 'models/Spending';
import 'components/Expenses/ExpensesView/styles/ExpensesView.scss';

export default function ExpensesView(): JSX.Element {
const { result: expenses } = useSpendingFiltered(SpendingType.Expense);
const { isLoading, result: expenses } = useSpendingFiltered(SpendingType.Expense);

function EmptyState(): JSX.Element {
return (
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function ExpensesView(): JSX.Element {
);
}

if (expenses.length === 0) {
if (expenses.length === 0 && !isLoading) {
return <EmptyState />;
}

Expand Down
34 changes: 28 additions & 6 deletions ui/components/FundingSchedules/FundingScheduleListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, { Fragment, useMemo, useState } from 'react';
import { MoreVert, AttachMoney, Remove, Weekend } from '@mui/icons-material';
import { Divider, IconButton, ListItem, Menu, MenuItem } from '@mui/material';
import { Divider, IconButton, ListItem, Menu, MenuItem, Skeleton } from '@mui/material';
import moment from 'moment';

import { useFundingSchedule, useUpdateFundingSchedule } from 'hooks/fundingSchedules';
import { useSpendingSink } from 'hooks/spending';
import formatAmount from 'util/formatAmount';
import getColor from 'util/getColor';
import getFundingScheduleContribution from 'util/getFundingScheduleContribution';
import FundingSchedule from 'models/FundingSchedule';
import { showRemoveFundingScheduleDialog } from './RemoveFundingScheduleDialog';
import { useNextFundingForecast } from 'hooks/forecast';

interface Props {
fundingScheduleId: number;
Expand All @@ -22,8 +21,8 @@ export default function FundingScheduleListItem(props: Props): JSX.Element {

const schedule = useFundingSchedule(props.fundingScheduleId);
const updateFundingSchedule = useUpdateFundingSchedule();
const { result: spending } = useSpendingSink();
const contribution = getFundingScheduleContribution(props.fundingScheduleId, spending);
const contributionForecast = useNextFundingForecast(props.fundingScheduleId);

const color = useMemo(() => getColor(schedule.name), [schedule.name]);

const next = schedule.nextOccurrence;
Expand All @@ -50,6 +49,29 @@ export default function FundingScheduleListItem(props: Props): JSX.Element {
});
}

function Contribution(): JSX.Element {
if (contributionForecast.isLoading) {
return (
// TODO This will break with the next MUI upgrade.
<Skeleton variant="text" width={80} height={28} />
);
}

if (contributionForecast.result) {
return (
<Fragment>
{ formatAmount(contributionForecast.result) }
</Fragment>
)
}

return (
<Fragment>
N/A
</Fragment>
)
}

return (
<Fragment>
<ListItem>
Expand All @@ -70,7 +92,7 @@ export default function FundingScheduleListItem(props: Props): JSX.Element {
Next Contribution
</span>
<span className="sm:text-lg font-normal text-gray-500 text-sm">
{ formatAmount(contribution) }
<Contribution />
</span>
</div>
<div className="flex h-full flex-col items-center justify-center">
Expand Down
34 changes: 32 additions & 2 deletions ui/hooks/forecast.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useQuery, UseQueryResult } from 'react-query';

import { useSelectedBankAccountId } from 'hooks/bankAccounts';
import { SpendingType } from 'models/Spending';
import request from 'util/request';
Expand All @@ -20,6 +22,34 @@ export function useSpendingForecast(): (spending: SpendingBareMinimum) => Promis
return async function (spending: SpendingBareMinimum): Promise<SpendingForecast> {
return request()
.post<SpendingForecast>(`/bank_accounts/${ selectedBankAccountId }/forecast/spending`, spending)
.then(result => result.data)
}
.then(result => result.data);
};
}

interface NextFundingResponse {
nextContribution: number;
}

export type NextFundingResult =
{ result: number | null }
& UseQueryResult<Partial<NextFundingResponse>>;

export function useNextFundingForecast(fundingScheduleId: number): NextFundingResult {
const selectedBankAccountId = useSelectedBankAccountId();
const result = useQuery<Partial<NextFundingResponse>>(
[
`/bank_accounts/${ selectedBankAccountId }/forecast/next_funding`,
{
fundingScheduleId,
},
],
{
enabled: !!selectedBankAccountId,
}
);

return {
...result,
result: result?.data?.nextContribution,
};
}
6 changes: 5 additions & 1 deletion ui/hooks/spending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ export function useSpendingSink(): SpendingResult {
}

export function useSpending(spendingId?: number): Spending | null {
const { result } = useSpendingSink();
if (!spendingId) {
return null;
}

const { result } = useSpendingSink();
return result.find(item => item.spendingId === spendingId) || null;
}

Expand Down Expand Up @@ -77,6 +77,7 @@ export function useRemoveSpending(): (_spendingId: number) => Promise<void> {
(previous: Array<Partial<Spending>>) => previous.filter(item => item.spendingId !== removedSpendingId),
),
queryClient.invalidateQueries(`/bank_accounts/${ selectedBankAccountId }/balances`),
queryClient.invalidateQueries([`/bank_accounts/${ selectedBankAccountId }/forecast/next_funding`]),
]),
},
);
Expand Down Expand Up @@ -105,6 +106,7 @@ export function useUpdateSpending(): (_spending: Spending) => Promise<void> {
previous.map(item => item.spendingId === updatedSpending.spendingId ? updatedSpending : item),
),
queryClient.invalidateQueries(`/bank_accounts/${ updatedSpending.bankAccountId }/balances`),
queryClient.invalidateQueries([`/bank_accounts/${ updatedSpending.bankAccountId }/forecast/next_funding`]),
]),
},
);
Expand Down Expand Up @@ -132,6 +134,7 @@ export function useCreateSpending(): (_spending: Spending) => Promise<Spending>
(previous: Array<Partial<Spending>>) => (previous || []).concat(createdSpending),
),
queryClient.invalidateQueries(`/bank_accounts/${ createdSpending.bankAccountId }/balances`),
queryClient.invalidateQueries([`/bank_accounts/${ createdSpending.bankAccountId }/forecast/next_funding`]),
]),
},
);
Expand Down Expand Up @@ -183,6 +186,7 @@ export function useTransfer(): (
...result.balance,
}),
),
queryClient.invalidateQueries([`/bank_accounts/${ selectedBankAccountId }/forecast/next_funding`]),
]),
},
);
Expand Down

0 comments on commit 5c918c7

Please sign in to comment.