Files
actual/packages/loot-core/src/client/data-hooks/schedules.tsx
Joel Jeremy Marquez 490ec22b8a Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts (#4547)
* Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts

* Fix lint error

* Release notes

* Fix typo

* Move backups to budgetfiles folder

* Move backup snapshot

* Fix lint

* Fix lint
2025-04-08 08:21:35 -07:00

210 lines
5.2 KiB
TypeScript

// @ts-strict-ignore
import React, {
createContext,
type PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports -- fix me -- do not import @actual-app/web in loot-core
import { useSyncedPref } from '@actual-app/web/src/hooks/useSyncedPref';
import { q, type Query } from '../../shared/query';
import {
getHasTransactionsQuery,
getStatus,
getStatusLabel,
} from '../../shared/schedules';
import {
type AccountEntity,
type ScheduleEntity,
type TransactionEntity,
} from '../../types/models';
import { accountFilter } from '../queries';
import { type LiveQuery, liveQuery } from '../query-helpers';
export type ScheduleStatusType = ReturnType<typeof getStatus>;
export type ScheduleStatuses = Map<ScheduleEntity['id'], ScheduleStatusType>;
export type ScheduleStatusLabelType = ReturnType<typeof getStatusLabel>;
export type ScheduleStatusLabels = Map<
ScheduleEntity['id'],
ScheduleStatusLabelType
>;
function loadStatuses(
schedules: readonly ScheduleEntity[],
onData: (data: ScheduleStatuses) => void,
onError: (error: Error) => void,
upcomingLength: string,
) {
return liveQuery<TransactionEntity>(getHasTransactionsQuery(schedules), {
onData: data => {
const hasTrans = new Set(data.filter(Boolean).map(row => row.schedule));
const scheduleStatuses = new Map(
schedules.map(s => [
s.id,
getStatus(
s.next_date,
s.completed,
hasTrans.has(s.id),
upcomingLength,
),
]),
) as ScheduleStatuses;
onData?.(scheduleStatuses);
},
onError,
});
}
type UseSchedulesProps = {
query?: Query;
};
type ScheduleData = {
schedules: readonly ScheduleEntity[];
statuses: ScheduleStatuses;
statusLabels: ScheduleStatusLabels;
};
type UseSchedulesResult = ScheduleData & {
readonly isLoading: boolean;
readonly error?: Error;
};
export function useSchedules({
query,
}: UseSchedulesProps = {}): UseSchedulesResult {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [data, setData] = useState<ScheduleData>({
schedules: [],
statuses: new Map(),
statusLabels: new Map(),
});
const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength');
const scheduleQueryRef = useRef<LiveQuery<ScheduleEntity> | null>(null);
const statusQueryRef = useRef<LiveQuery<TransactionEntity> | null>(null);
useEffect(() => {
let isUnmounted = false;
setError(undefined);
if (!query) {
// This usually means query is not yet set on this render cycle.
return;
}
function onError(error: Error) {
if (!isUnmounted) {
setError(error);
setIsLoading(false);
}
}
if (query.state.table !== 'schedules') {
onError(new Error('Query must be a schedules query.'));
return;
}
setIsLoading(true);
scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, {
onData: async schedules => {
statusQueryRef.current = loadStatuses(
schedules,
(statuses: ScheduleStatuses) => {
if (!isUnmounted) {
setData({
schedules,
statuses,
statusLabels: new Map(
[...statuses.keys()].map(key => [
key,
getStatusLabel(statuses.get(key)),
]),
),
});
setIsLoading(false);
}
},
onError,
upcomingLength,
);
},
onError,
});
return () => {
isUnmounted = true;
scheduleQueryRef.current?.unsubscribe();
statusQueryRef.current?.unsubscribe();
};
}, [query, upcomingLength]);
return {
isLoading,
error,
...data,
};
}
type SchedulesContextValue = UseSchedulesResult;
const SchedulesContext = createContext<SchedulesContextValue | undefined>(
undefined,
);
type SchedulesProviderProps = PropsWithChildren<{
query?: UseSchedulesProps['query'];
}>;
export function SchedulesProvider({ query, children }: SchedulesProviderProps) {
const data = useSchedules({ query });
return (
<SchedulesContext.Provider value={data}>
{children}
</SchedulesContext.Provider>
);
}
export function useCachedSchedules() {
const context = useContext(SchedulesContext);
if (!context) {
throw new Error(
'useCachedSchedules must be used within a SchedulesProvider',
);
}
return context;
}
export function accountSchedulesQuery(
accountId?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
) {
const filterByAccount = accountFilter(accountId, '_account');
const filterByPayee = accountFilter(accountId, '_payee.transfer_acct');
let query = q('schedules')
.select('*')
.filter({
$and: [{ '_account.closed': false }],
});
if (accountId) {
if (accountId === 'uncategorized') {
query = query.filter({ next_date: null });
} else {
query = query.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return query.orderBy({ next_date: 'desc' });
}