mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 03:23:51 -05:00
* 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
210 lines
5.2 KiB
TypeScript
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' });
|
|
}
|