From 18352fdbb2364bea38476f99bb484642954d326d Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 16:13:44 +0200 Subject: [PATCH 01/43] Fix Dockerfile build args --- docker/postgres/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index ef77aed1..019fbf17 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,9 +1,11 @@ ARG POSTGRES_MAJOR=16 -ARG POSTGRES_MINOR=16.10 +ARG POSTGRES_MINOR=10 ARG POSTGRES_BASE=trixie FROM postgres:${POSTGRES_MAJOR}.${POSTGRES_MINOR}-${POSTGRES_BASE} +ARG POSTGRES_MAJOR + # Install pg_cron (from PGDG packages) RUN apt-get update && apt-get install -y \ postgresql-${POSTGRES_MAJOR}-cron \ From 77c3c22b94c0421de36bb912c6660022f98b9a8b Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 16:18:37 +0200 Subject: [PATCH 02/43] Fix docker manifest tags --- .github/workflows/postgres.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/postgres.yaml b/.github/workflows/postgres.yaml index 2995059c..69f86c2f 100644 --- a/.github/workflows/postgres.yaml +++ b/.github/workflows/postgres.yaml @@ -156,7 +156,7 @@ jobs: --amend ghcr.io/oss-apps/postgres-amd64:$GIT_SHA \ --amend ghcr.io/oss-apps/postgres-arm64:$GIT_SHA \ - docker manifest push ghcr.io/oss-apps/postgres:$CHANNEL + docker manifest push ghcr.io/oss-apps/postgres:$DB_VERSION docker manifest push ghcr.io/oss-apps/postgres:$GIT_SHA - name: Create and push "latest" tag if applicable From 0f279d975f7aed883778ea546ea7ebd59b313762 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 16:37:11 +0200 Subject: [PATCH 03/43] Try fixing the latest boolean --- .github/workflows/postgres.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/postgres.yaml b/.github/workflows/postgres.yaml index 69f86c2f..de8ee1fd 100644 --- a/.github/workflows/postgres.yaml +++ b/.github/workflows/postgres.yaml @@ -160,7 +160,7 @@ jobs: docker manifest push ghcr.io/oss-apps/postgres:$GIT_SHA - name: Create and push "latest" tag if applicable - if: ${{ github.event.inputs.is_latest == true }} + if: github.event.inputs.is_latest == 'true' run: | docker manifest create \ ossapps/postgres:latest \ From e70819083e62d97928b6ba55e4f5733b5934c275 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 19:44:34 +0200 Subject: [PATCH 04/43] Add pg_cron config commands --- docker/dev/compose.yml | 5 +++++ docker/prod/compose.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index dda1b9a2..ca98990d 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -12,6 +12,11 @@ services: - POSTGRES_PORT=${POSTGRES_PORT:-5432} volumes: - database:/var/lib/postgresql/data + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB:-splitpro} + -c cron.timezone=${TZ:-UTC} ports: - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' command: > diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index c68c9f67..3bba376d 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -15,6 +15,11 @@ services: interval: 10s timeout: 5s retries: 5 + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB:-splitpro} + -c cron.timezone=${TZ:-UTC} # ports: # - "5432:5432" env_file: .env From 562db3319831e9dee5ff46426593780a09caa72c Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 20:05:10 +0200 Subject: [PATCH 05/43] Unify action and submit button --- src/components/AddExpense/AddExpensePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index a8c6afa4..b900716f 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -297,7 +297,7 @@ export const AddOrEditExpensePage: React.FC<{ } onClick={addExpense} > - {t('actions.submit')} + {t('actions.save')} From d5d26fe4e94f39bd177d74894d41689c589ffdec Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 20 Sep 2025 21:04:48 +0200 Subject: [PATCH 06/43] Add UI for setting recurrence rules in a expense --- src/components/AddExpense/AddExpensePage.tsx | 97 ++++++++++- src/components/AddExpense/DateSelector.tsx | 4 +- src/components/ui/collapsible.tsx | 32 ++++ src/components/ui/select.tsx | 170 +++++++++++++++++++ src/store/addStore.ts | 22 ++- src/styles/globals.css | 24 +++ 6 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/select.tsx diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index b900716f..ebafa896 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -1,4 +1,4 @@ -import { HeartHandshakeIcon, Landmark, X } from 'lucide-react'; +import { HeartHandshakeIcon, Landmark, RefreshCcwDot, RefreshCwOff, X } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -15,7 +15,17 @@ import { cn } from '~/lib/utils'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { Button } from '../ui/button'; import { CURRENCY_CONVERSION_ICON } from '../ui/categoryIcons'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'; import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; import AddBankTransactions from './AddBankTransactions'; import { CategoryPicker } from './CategoryPicker'; import { CurrencyPicker } from './CurrencyPicker'; @@ -275,13 +285,8 @@ export const AddOrEditExpensePage: React.FC<{ <> -
- +
+
{isStorageConfigured ? : null} + +
+ + +
+ + +
+
+ + ); +}; + const SponsorUs = () => { const { t } = useTranslation(); return ( diff --git a/src/components/AddExpense/DateSelector.tsx b/src/components/AddExpense/DateSelector.tsx index 2968096a..d3b1bcd5 100644 --- a/src/components/AddExpense/DateSelector.tsx +++ b/src/components/AddExpense/DateSelector.tsx @@ -16,11 +16,11 @@ export const DateSelector: React.FC = (cal + + ), + [], + ); + return ( <> {t('navigation.activity')} - +
{!expensesQuery.data?.length ? (
{t('ui.no_activity')}
@@ -86,18 +104,16 @@ const ActivityPage: NextPageWithUser = ({ user }) => {

)} -
- {getPaymentString( - user, - e.expense.amount, - e.expense.paidBy, - e.amount, - e.expense.splitType === SplitType.SETTLEMENT, - e.expense.currency, - t, - !!e.expense.deletedBy, - )} -
+ {getPaymentString( + user, + e.expense.amount, + e.expense.paidBy, + e.amount, + e.expense.splitType === SplitType.SETTLEMENT, + e.expense.currency, + t, + !!e.expense.deletedBy, + )}

{toUIDate(e.expense.expenseDate)}

diff --git a/src/pages/recurring.tsx b/src/pages/recurring.tsx new file mode 100644 index 00000000..1ef5aa27 --- /dev/null +++ b/src/pages/recurring.tsx @@ -0,0 +1,46 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import MainLayout from '~/components/Layout/MainLayout'; +import { EntityAvatar } from '~/components/ui/avatar'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { type NextPageWithUser } from '~/types'; +import { api } from '~/utils/api'; +import { withI18nStaticProps } from '~/utils/i18n/server'; + +const RecurringPage: NextPageWithUser = ({ user }) => { + const { displayName, t, toUIDate } = useTranslationWithUtils(); + + const recurringExpensesQuery = api.expense.getRecurringExpenses.useQuery(); + + return ( + <> + + {t('navigation.recurring')} + + + +
+ {!recurringExpensesQuery.data?.length ? ( +
{t('ui.recurring.empty')}
+ ) : null} + {recurringExpensesQuery.data?.map((e) => ( + +
+ +
+
+

{toUIDate(e.expense.expenseDate)}

+
+ + ))} +
+
+ + ); +}; + +RecurringPage.auth = true; + +export const getStaticProps = withI18nStaticProps(['common']); + +export default RecurringPage; diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index d6f26374..05e13891 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -393,6 +393,41 @@ export const expenseRouter = createTRPCRouter({ return expenses; }), + getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => { + const expenses = await db.expenseParticipant.findMany({ + where: { + userId: ctx.session.user.id, + expense: { + NOT: { + recurrenceId: null, + }, + }, + }, + orderBy: { + expense: { + createdAt: 'desc', + }, + }, + include: { + expense: { + include: { + recurrence: true, + addedByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + }, + }, + }, + }); + + return expenses; + }), + getUploadUrl: protectedProcedure .input(z.object({ fileName: z.string(), fileType: z.string(), fileSize: z.number() })) .mutation(async ({ input, ctx }) => { From e03169dc0511edea8dc82a044817674aaec5b750 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 30 Sep 2025 23:38:42 +0200 Subject: [PATCH 21/43] Fix instrumentation import --- src/instrumentation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/instrumentation.ts b/src/instrumentation.ts index e792cf3d..2437776d 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,3 @@ -import { checkRecurrenceNotifications } from './server/api/services/notificationService'; - /** * Add things here to be executed during server startup. * @@ -11,6 +9,9 @@ export async function register() { const { validateAuthEnv } = await import('./server/auth'); validateAuthEnv(); + const { checkRecurrenceNotifications } = await import( + './server/api/services/notificationService' + ); console.log('Starting recurrent expense notification checking...'); setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds } From 058645b31b3ffa28846c3356b5f63aa7bdca212c Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 17:05:34 +0200 Subject: [PATCH 22/43] Post rebase schema fix --- prisma/schema.prisma | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee3f87ca..c1ff91cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -73,14 +73,16 @@ model User { } model CachedBankData { - id Int @id @default(autoincrement()) - obapiProviderId String @unique - data String - userId Int - user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) - lastFetched DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + obapiProviderId String @unique + data String + userId Int + user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) + lastFetched DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@schema("public") } model VerificationToken { From feab91fb6071982b7012b78c10f9d702fc696890 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 17:54:44 +0200 Subject: [PATCH 23/43] Integrate with partial changes from banktransction PR --- .env.example | 1 + docker/dev/compose.yml | 5 - docker/prod/compose.yml | 5 - src/components/AddExpense/AddExpensePage.tsx | 123 ++++-------------- src/components/AddExpense/RecurrenceInput.tsx | 68 ++++++++++ src/instrumentation.ts | 5 - src/store/addStore.ts | 3 +- 7 files changed, 95 insertions(+), 115 deletions(-) create mode 100644 src/components/AddExpense/RecurrenceInput.tsx diff --git a/.env.example b/.env.example index 7c8ec57b..80898073 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ POSTGRES_USER="postgres" POSTGRES_PASSWORD="strong-password" POSTGRES_DB="splitpro" POSTGRES_PORT=5432 +TZ="UTC" # Set to your timezone, e.g. Europe/Warsaw. Required for scheduled jobs to execute on time. DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_CONTAINER_NAME}:${POSTGRES_PORT}/${POSTGRES_DB}" # Next Auth diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index ca98990d..7f3fc8e3 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -19,11 +19,6 @@ services: -c cron.timezone=${TZ:-UTC} ports: - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' - command: > - postgres - -c shared_preload_libraries=pg_cron - -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} minio: image: minio/minio:RELEASE.2025-04-22T22-12-26Z diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index 3bba376d..77abf810 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -25,11 +25,6 @@ services: env_file: .env volumes: - database:/var/lib/postgresql/data - command: > - postgres - -c shared_preload_libraries=pg_cron - -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} splitpro: image: ossapps/splitpro:latest diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index ebafa896..826091ce 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -15,21 +15,12 @@ import { cn } from '~/lib/utils'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { Button } from '../ui/button'; import { CURRENCY_CONVERSION_ICON } from '../ui/categoryIcons'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'; import { Input } from '../ui/input'; -import { Label } from '../ui/label'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from '../ui/select'; import AddBankTransactions from './AddBankTransactions'; import { CategoryPicker } from './CategoryPicker'; import { CurrencyPicker } from './CurrencyPicker'; import { DateSelector } from './DateSelector'; +import { RecurrenceInput } from './RecurrenceInput'; import { SelectUserOrGroup } from './SelectUserOrGroup'; import { SplitTypeSection } from './SplitTypeSection'; import { UploadFile } from './UploadFile'; @@ -58,6 +49,7 @@ export const AddOrEditExpensePage: React.FC<{ const splitType = useAddExpenseStore((s) => s.splitType); const fileKey = useAddExpenseStore((s) => s.fileKey); const transactionId = useAddExpenseStore((s) => s.transactionId); + const repeatInterval = useAddExpenseStore((s) => s.repeatInterval); const { setCurrency, @@ -286,7 +278,12 @@ export const AddOrEditExpensePage: React.FC<{
- +
{isStorageConfigured ? : null}
-
- {/* place for recurring button */} +
+ + +
- - +
@@ -344,86 +345,10 @@ export const AddOrEditExpensePage: React.FC<{ ); }; -const DateSettings: React.FC = () => { - const { t } = useTranslation(); - const expenseDate = useAddExpenseStore((s) => s.expenseDate); - const { setExpenseDate, setRepeatInterval, setRepeatEvery, unsetRepeat } = useAddExpenseStore( - (s) => s.actions, - ); - const repeatInterval = useAddExpenseStore((s) => s.repeatInterval); - const repeatEvery = useAddExpenseStore((s) => s.repeatEvery); - - const toggleRecurring = useCallback(() => { - if (repeatInterval) { - unsetRepeat(); - } else { - setRepeatInterval('MONTHLY'); - } - }, [repeatInterval, setRepeatInterval, unsetRepeat]); - - const onRepeatEveryChange = useCallback( - (e: React.ChangeEvent) => { - const val = Math.min(99, Math.max(1, Number(e.target.value))); - if (!Number.isNaN(val)) { - setRepeatEvery(val); - } - }, - [setRepeatEvery], - ); - - if (!expenseDate) { - return ; - } - - return ( - -
- - - - -
- - -
- - -
-
-
- ); -}; - const SponsorUs = () => { const { t } = useTranslation(); return ( -
+
+ ); + }, +); + +GridButton.displayName = 'GridButton'; + +// ScheduleFields component to handle layout complexity +interface ScheduleFieldsProps { + scheduleType: string; + renderDaysOfWeekList: () => React.ReactNode; + renderMonthsGrid: () => React.ReactNode; + renderDaysOfMonthGrid: () => React.ReactNode; + renderHoursGrid: () => React.ReactNode; + renderMinutesGrid: () => React.ReactNode; +} + +const ScheduleFields = React.memo( + ({ + scheduleType, + renderDaysOfWeekList, + renderMonthsGrid, + renderDaysOfMonthGrid, + renderHoursGrid, + renderMinutesGrid, + }) => { + if (scheduleType === 'week') { + return ( +
+
{renderDaysOfWeekList()}
+
+ {renderHoursGrid()} + {renderMinutesGrid()} +
+
+ ); + } + + if (scheduleType === 'year') { + return ( +
+
{renderMonthsGrid()}
+
{renderDaysOfMonthGrid()}
+
{renderHoursGrid()}
+
{renderMinutesGrid()}
+
+ ); + } + + if (scheduleType === 'month') { + return ( +
+
{renderDaysOfMonthGrid()}
+
+ {renderHoursGrid()} + {renderMinutesGrid()} +
+
+ ); + } + + if (scheduleType === 'day') { + return ( +
+ {renderHoursGrid()} + {renderMinutesGrid()} +
+ ); + } + + if (scheduleType === 'hour') { + return
{renderMinutesGrid()}
; + } + + return null; + }, +); + +ScheduleFields.displayName = 'ScheduleFields'; + +export function CronBuilder({ onChange, defaultValue, className }: CronBuilderProps) { + const defaultSchedule = defaultValue || '0 0 * * 0'; // Use provided default or fallback + + // Helper function to parse cron expression and determine schedule type + const parseCronExpression = (cronExpr: string): ParsedCron => { + if (!cronExpr || cronExpr === '') return { type: 'never', values: {} }; + + // Clean and validate the cron expression + const cleanExpr = cronExpr.trim(); + const parts = cleanExpr.split(' '); + if (parts.length !== 5) return { type: 'custom', values: { custom: cleanExpr } }; + + const [min, hour, dom, month, dow] = parts; + + // Helper to safely parse numeric values from cron parts + const parseNumbers = (part?: string): number[] => { + if (part === '*' || part === '?' || !part) return []; + return part + .split(',') + .map((v) => parseInt(v.trim(), 10)) + .filter((v) => !isNaN(v)); + }; + + try { + // Check for standard patterns + if (min !== '*' && hour === '*' && dom === '*' && month === '*' && dow === '?') { + return { type: 'hour', values: { minutes: parseNumbers(min) } }; + } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow === '?') { + return { + type: 'day', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + }, + }; + } else if (min !== '*' && hour !== '*' && dom === '?' && month === '*' && dow !== '*') { + return { + type: 'week', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + daysOfWeek: parseNumbers(dow), + }, + }; + } else if (min !== '*' && hour !== '*' && dom !== '*' && month === '*' && dow === '?') { + return { + type: 'month', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + daysOfMonth: parseNumbers(dom), + }, + }; + } else { + return { type: 'custom', values: { custom: cleanExpr } }; + } + } catch (error) { + console.warn('Error parsing cron expression:', error); + return { type: 'custom', values: { custom: cleanExpr } }; + } + }; + + const initialParsed = parseCronExpression(defaultSchedule); + + const [scheduleType, setScheduleType] = useState(initialParsed.type); + const [minutes, setMinutes] = useState(initialParsed.values.minutes || [0]); + const [hours, setHours] = useState(initialParsed.values.hours || [0]); + const [daysOfMonth, setDaysOfMonth] = useState(initialParsed.values.daysOfMonth || [1]); + const [months, setMonths] = useState(initialParsed.values.months || [1]); + const [daysOfWeek, setDaysOfWeek] = useState(initialParsed.values.daysOfWeek || [0]); + const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); + const [cronExpression, setCronExpression] = useState(defaultSchedule); + const [showAllMinutes, setShowAllMinutes] = useState(false); + + function loadDefaults() { + setMinutes([0]); + setHours([0]); + setDaysOfMonth([1]); + setMonths([1]); + setDaysOfWeek([0]); + } + + useEffect(() => { + let expression = ''; + + // Filter out undefined/null values and ensure valid arrays + const cleanMonths = (months || []).filter((v) => v !== undefined && v !== null); + const cleanDaysOfMonth = (daysOfMonth || []).filter((v) => v !== undefined && v !== null); + const cleanDaysOfWeek = (daysOfWeek || []).filter((v) => v !== undefined && v !== null); + const cleanHours = (hours || []).filter((v) => v !== undefined && v !== null); + const cleanMinutes = (minutes || []).filter((v) => v !== undefined && v !== null); + + const monthsCSV = cleanMonths.length === 12 ? '*' : cleanMonths.join(','); + const domCSV = cleanDaysOfMonth.length === 31 ? '*' : cleanDaysOfMonth.join(','); + const dowCSV = cleanDaysOfWeek.length === 7 ? '*' : cleanDaysOfWeek.join(','); + const hoursCSV = cleanHours.length === 24 ? '*' : cleanHours.join(','); + const minutesCSV = cleanMinutes.length === 60 ? '*' : cleanMinutes.join(','); + + switch (scheduleType) { + case 'hour': + expression = `${minutesCSV} * * * ?`; + break; + case 'day': + expression = `${minutesCSV} ${hoursCSV} * * ?`; + break; + case 'week': + expression = `${minutesCSV} ${hoursCSV} ? * ${dowCSV}`; + break; + case 'month': + expression = `${minutesCSV} ${hoursCSV} ${domCSV} * ?`; + break; + case 'year': + expression = `${minutesCSV} ${hoursCSV} ${domCSV} ${monthsCSV} ?`; + break; + case 'custom': + expression = custom || ''; + break; + default: + expression = ''; + } + + if (getCronText(expression).status) { + setCronExpression(expression); + onChange(expression); + } else { + setCronExpression(''); + onChange(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scheduleType, minutes, hours, daysOfMonth, months, daysOfWeek, custom]); + + const handleMonthToggle = useCallback((monthIndex: number | string) => { + const monthNum = (typeof monthIndex === 'number' ? monthIndex : parseInt(monthIndex, 10)) + 1; + setMonths((prev) => + prev.includes(monthNum) ? prev.filter((m) => m !== monthNum) : [...prev, monthNum], + ); + }, []); + + // Month button component with pressed state + interface MonthButtonProps { + month: string; + index: number; + isSelected: boolean; + onClick: (index: number) => void; + } + + const MonthButton = React.memo(({ month, index, isSelected, onClick }) => { + const [isPressed, setIsPressed] = useState(false); + + return ( + + ); + }); + + MonthButton.displayName = 'MonthButton'; + + const renderMonthsGrid = () => ( +
+ +
+ {MONTHS_SHORT.map((month, index) => ( + + ))} +
+
+ ); + + const handleDayOfWeekToggle = useCallback((dayIndex: number | string) => { + const dayNum = typeof dayIndex === 'number' ? dayIndex : parseInt(dayIndex, 10); + setDaysOfWeek((prev) => + prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], + ); + }, []); + + // Day of week button component with pressed state + interface DayOfWeekButtonProps { + day: string; + index: number; + isSelected: boolean; + isWeekend: boolean; + onClick: (index: number) => void; + } + + const DayOfWeekButton = React.memo( + ({ day, index, isSelected, isWeekend, onClick }) => { + const [isPressed, setIsPressed] = useState(false); + + return ( + + ); + }, + ); + + DayOfWeekButton.displayName = 'DayOfWeekButton'; + + const renderDaysOfWeekList = () => { + const weekendDays = [0, 6]; + + return ( +
+ +
+ {DAYS_SHORT.map((day, index) => { + const isWeekend = weekendDays.includes(index); + const isSelected = daysOfWeek.includes(index); + return ( + + ); + })} +
+
+ ); + }; + + const handleDayOfMonthToggle = useCallback((day: number | string) => { + const dayNum = typeof day === 'number' ? day : parseInt(day, 10); + setDaysOfMonth((prev) => + prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], + ); + }, []); + + const renderDaysOfMonthGrid = () => ( +
+ +
+ {DAYS_OF_MONTH.map((day) => ( + + ))} +
+
+ ); + + const handleHourToggle = useCallback((hour: number | string) => { + const hourNum = typeof hour === 'number' ? hour : parseInt(hour, 10); + setHours((prev) => + prev.includes(hourNum) ? prev.filter((h) => h !== hourNum) : [...prev, hourNum], + ); + }, []); + + const renderHoursGrid = () => ( +
+ +
+ {HOURS.map((hour) => ( + + ))} +
+
+ ); + + const handleMinuteToggle = useCallback((minute: number | string) => { + const minuteNum = typeof minute === 'number' ? minute : parseInt(minute, 10); + setMinutes((prev) => + prev.includes(minuteNum) ? prev.filter((m) => m !== minuteNum) : [...prev, minuteNum], + ); + }, []); + + const minutesToShow = useMemo( + () => (showAllMinutes ? MINUTES : COMMON_MINUTES), + [showAllMinutes], + ); + + const renderMinutesGrid = () => ( +
+
+ + ({showAllMinutes ? 'All' : 'Common'}) + +
+
+ {minutesToShow.map((minute) => ( + + ))} +
+
+ ); + + // Render Cron expression builder UI + return ( +
+
+
+ { + if (value) { + loadDefaults(); + setScheduleType(value); + } + }} + className="w-fit justify-start" + > + {SCHEDULE_TYPES.map(({ value, label }) => ( + + {label} + + ))} + +
+ + {scheduleType === 'never' ? ( +
No schedule configured
+ ) : ( +
+ {scheduleType === 'custom' ? ( +
+ + { + setCustom(event.target.value); + }} + className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-[50%] rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none" + placeholder="0 0 * * 0" + /> +
+ ) : ( + + )} + + {(() => { + const cronString = getCronText(cronExpression); + if (cronString.status) + return ( +

+ {cronString.value}{' '} + + cron({cronExpression}) + +

+ ); + else + return ( +

+ Invalid cron expression +

+ ); + })()} +
+ )} +
+
+ ); +} + +export default CronBuilder; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 00000000..c977117c --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; +import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'; +import { type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; +import { toggleVariants } from '~/components/ui/toggle'; + +const ToggleGroupContext = React.createContext>({ + size: 'default', + variant: 'default', +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + {children} + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 00000000..e7cd3ed6 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Toggle as TogglePrimitive } from 'radix-ui'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-2 min-w-9', + sm: 'h-8 px-1.5 min-w-8', + lg: 'h-10 px-2.5 min-w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants }; From 0abc2bdfb43f93a1fa5749cdd3d6285b5e0d202e Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 18:39:47 +0200 Subject: [PATCH 25/43] Localize calendar component --- src/components/ui/calendar.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 4b8959c1..ac1c817e 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; import * as React from 'react'; -import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; - +import { type DayButton, DayPicker, getDefaultClassNames, Locale } from 'react-day-picker'; import { Button, buttonVariants } from '~/components/ui/button'; import { cn } from '~/lib/utils'; @@ -18,9 +18,26 @@ function Calendar({ buttonVariant?: React.ComponentProps['variant']; }) { const defaultClassNames = getDefaultClassNames(); + const { i18n } = useTranslation(); + const [locale, setLocale] = React.useState(undefined); + React.useEffect(() => { + const userLocale = i18n.language; + (async () => { + try { + const [first, second] = i18n.language.split('-'); + const key = `${first}${second?.toUpperCase() ?? ''}`; + // @ts-ignore + const { [key]: dayPickerLocale } = await import('react-day-picker/locale'); + setLocale(dayPickerLocale); + } catch (e) { + setLocale(undefined); + } + })(); + }, [i18n]); return ( Date: Mon, 6 Oct 2025 20:05:22 +0200 Subject: [PATCH 26/43] Replace obsolete timestamp of gocardless migration --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename prisma/migrations/{20241026095834_add_gocardless_bank_transaction_integration => 202509052609583_add_gocardless_bank_transaction_integration}/migration.sql (100%) diff --git a/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/202509052609583_add_gocardless_bank_transaction_integration/migration.sql similarity index 100% rename from prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql rename to prisma/migrations/202509052609583_add_gocardless_bank_transaction_integration/migration.sql From 78f74c5eac5c9ce94aa4d7ad661a2007b402cb8d Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 20:08:13 +0200 Subject: [PATCH 27/43] Improve schema and migration with recurrence job relation --- .../20250920192654_recurrence/migration.sql | 26 +++++++++++++---- prisma/schema.prisma | 29 +++++++------------ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql index c2067d11..cd8afe8d 100644 --- a/prisma/migrations/20250920192654_recurrence/migration.sql +++ b/prisma/migrations/20250920192654_recurrence/migration.sql @@ -1,6 +1,3 @@ --- CreateEnum -CREATE TYPE "public"."RecurrenceInterval" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'); - -- AlterTable ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; @@ -8,8 +5,6 @@ ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; CREATE TABLE "public"."ExpenseRecurrence" ( "id" SERIAL NOT NULL, "expenseId" TEXT NOT NULL, - "repeatEvery" INTEGER NOT NULL, - "repeatInterval" "public"."RecurrenceInterval" NOT NULL, "createdById" INTEGER NOT NULL, "jobId" BIGINT NOT NULL, "notified" BOOLEAN NOT NULL DEFAULT true, @@ -17,6 +12,9 @@ CREATE TABLE "public"."ExpenseRecurrence" ( CONSTRAINT "ExpenseRecurrence_pkey" PRIMARY KEY ("id") ); +-- CreateIndex +CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence"("jobId"); + -- CreateIndex CREATE UNIQUE INDEX "ExpenseRecurrence_expenseId_key" ON "public"."ExpenseRecurrence"("expenseId"); @@ -66,5 +64,21 @@ DO $$ BEGIN IF current_database() NOT LIKE 'prisma_migrate_shadow_db%' THEN CREATE EXTENSION IF NOT EXISTS pg_cron; +ELSE + CREATE SCHEMA IF NOT EXISTS cron; + CREATE TABLE IF NOT EXISTS cron.job ( + jobid BIGINT PRIMARY KEY, + schedule TEXT, + command TEXT, + nodename TEXT, + nodeport INTEGER, + database TEXT, + username TEXT, + active BOOLEAN, + jobname TEXT + ); END IF; -END $$; \ No newline at end of file +END $$; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "cron"."job"("jobid") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c1ff91cd..dfafb643 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,25 +224,15 @@ model ExpenseNote { @@schema("public") } -enum RecurrenceInterval { - DAILY - WEEKLY - MONTHLY - YEARLY - - @@schema("public") -} - model ExpenseRecurrence { - id Int @id @default(autoincrement()) - repeatEvery Int - repeatInterval RecurrenceInterval - createdById Int - jobId BigInt - notified Boolean @default(true) - expenseId String @unique @db.Uuid - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + createdById Int + jobId BigInt @unique + notified Boolean @default(true) + expenseId String @unique @db.Uuid + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) @@schema("public") } @@ -277,7 +267,8 @@ model job { active Boolean @default(true) jobname String? - runDetails job_run_details[] @relation("job_run_details") + recurrence ExpenseRecurrence? @relation() + runDetails job_run_details[] @relation("job_run_details") @@unique([jobname, username], map: "jobname_username_uniq") @@schema("cron") From bfecc2e17b333d97f6e021bedbd1a23133295d8b Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 20:16:28 +0200 Subject: [PATCH 28/43] HIde bank transaction icon when no account is connected --- .../BankTransactions/BankingTransactionList.tsx | 11 +++++++---- src/components/ui/drawer.tsx | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx index 5397bbc3..8c82f155 100644 --- a/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx +++ b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx @@ -37,6 +37,9 @@ export const BankingTransactionList: React.FC<{ const userQuery = api.user.me.useQuery(); const transactions = api.bankTransactions.getTransactions.useQuery( userQuery.data?.obapiProviderId, + { + enabled: bankConnectionEnabled && !!userQuery.data?.obapiProviderId, + }, ); const expensesQuery = api.user.getOwnExpenses.useQuery(); @@ -68,10 +71,6 @@ export const BankingTransactionList: React.FC<{ return transaction?.group?.name ? ` to ${transaction.group.name}` : ''; }; - if (!bankConnectionEnabled) { - return null; - } - const transactionsArray = returnTransactionsArray(); const onTransactionRowClick = useCallback( @@ -114,6 +113,10 @@ export const BankingTransactionList: React.FC<{ // } }, []); + if (!bankConnectionEnabled || !userQuery.data?.obapiProviderId) { + return null; + } + return ( ; - className: string; + className?: string; open?: boolean; onOpenChange?: (open: boolean) => void; title?: string; From aa9f82f94d0e71992bfe243897bd6b95e3efd00b Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 21:09:13 +0200 Subject: [PATCH 29/43] Fix import issues due to incorrect base path --- src/pages/_app.tsx | 2 +- src/utils/i18n/server.ts | 2 +- tsconfig.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4316f179..44e372c0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,7 +8,7 @@ import { SessionProvider, useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; import { Toaster } from 'sonner'; import { appWithTranslation, useTranslation } from 'next-i18next'; -import i18nConfig from 'next-i18next.config.js'; +import i18nConfig from '@/next-i18next.config.js'; import { ThemeProvider } from '~/components/ui/theme-provider'; import '~/styles/globals.css'; import { LoadingSpinner } from '~/components/ui/spinner'; diff --git a/src/utils/i18n/server.ts b/src/utils/i18n/server.ts index 12a4f2fa..d34b7b74 100644 --- a/src/utils/i18n/server.ts +++ b/src/utils/i18n/server.ts @@ -1,6 +1,6 @@ import { type SSRConfig } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import i18nConfig from 'next-i18next.config.js'; +import i18nConfig from '@/next-i18next.config.js'; export const customServerSideTranslations = async ( locale: string | undefined, diff --git a/tsconfig.json b/tsconfig.json index 2409a5a1..d7cb57b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ /* Path Aliases */ "paths": { "~/*": ["./src/*"], - "*": ["./*"] + "@/*": ["./*"] } }, "include": [ From 36a4b9a745c621eda2484063ff314b0fe6f4f5be Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 22:20:20 +0200 Subject: [PATCH 30/43] Restyle cron builder and localize it --- public/locales/en/common.json | 21 +- src/components/AddExpense/AddExpensePage.tsx | 12 +- src/components/AddExpense/RecurrenceInput.tsx | 67 +--- src/components/ui/cron-builder.tsx | 326 ++++++------------ src/server/api/services/scheduleService.ts | 33 +- src/store/addStore.ts | 22 +- 6 files changed, 163 insertions(+), 318 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 58fc167c..4114719c 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -129,7 +129,8 @@ "unsubscribe_error": "Error unsubscribing notification", "upload_failed": "Failed to upload file", "uploading_error": "Error uploading file", - "valid_email": "Enter valid email" + "valid_email": "Enter valid email", + "invalid_cron_expression": "Invalid cron expression" }, "bank_transactions": { "choose_bank_provider": "Choose bank provider", @@ -304,6 +305,24 @@ "balances": "Balances", "groups": "Groups" }, + "recurrence": { + "title": "Recurrence", + "description": "Set up automatic recurrence for this expense.", + "cron_expression": "Cron Expression", + "time_of_day": "Time of Day", + "days_of_week": "Days of Week", + "days_of_month": "Days of Month", + "months": "Months", + "never": "No schedule configured", + "schedule_type": { + "never": "Never", + "custom": "Custom", + "day": "Daily", + "month": "Monthly", + "week": "Weekly", + "year": "Yearly" + } + }, "ui": { "added_by": "Added by", "and": "and", diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 826091ce..b865fbd3 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -1,4 +1,4 @@ -import { HeartHandshakeIcon, Landmark, RefreshCcwDot, RefreshCwOff, X } from 'lucide-react'; +import { HeartHandshakeIcon, Landmark, RefreshCcwDot, X } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -49,7 +49,7 @@ export const AddOrEditExpensePage: React.FC<{ const splitType = useAddExpenseStore((s) => s.splitType); const fileKey = useAddExpenseStore((s) => s.fileKey); const transactionId = useAddExpenseStore((s) => s.transactionId); - const repeatInterval = useAddExpenseStore((s) => s.repeatInterval); + const cronExpression = useAddExpenseStore((s) => s.cronExpression); const { setCurrency, @@ -309,7 +309,13 @@ export const AddOrEditExpensePage: React.FC<{
diff --git a/src/components/AddExpense/RecurrenceInput.tsx b/src/components/AddExpense/RecurrenceInput.tsx index 5c751c81..6ba9b854 100644 --- a/src/components/AddExpense/RecurrenceInput.tsx +++ b/src/components/AddExpense/RecurrenceInput.tsx @@ -1,68 +1,23 @@ -import React, { useCallback } from 'react'; import { useTranslation } from 'next-i18next'; +import React from 'react'; import { useAddExpenseStore } from '~/store/addStore'; +import { CronBuilder } from '../ui/cron-builder'; import { AppDrawer } from '../ui/drawer'; -import { Label } from '../ui/label'; -import { Input } from '../ui/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from '../ui/select'; export const RecurrenceInput: React.FC> = ({ children }) => { const { t } = useTranslation(); - const { setRepeatInterval, setRepeatEvery, unsetRepeat } = useAddExpenseStore((s) => s.actions); - const repeatInterval = useAddExpenseStore((s) => s.repeatInterval); - const repeatEvery = useAddExpenseStore((s) => s.repeatEvery); - const toggleRecurring = useCallback(() => { - if (repeatInterval) { - unsetRepeat(); - } else { - setRepeatInterval('MONTHLY'); - } - }, [repeatInterval, setRepeatInterval, unsetRepeat]); - - const onRepeatEveryChange = useCallback( - (e: React.ChangeEvent) => { - const val = Math.min(99, Math.max(1, Number(e.target.value))); - if (!Number.isNaN(val)) { - setRepeatEvery(val); - } - }, - [setRepeatEvery], - ); + const cronExpression = useAddExpenseStore((s) => s.cronExpression); + const setCronExpression = useAddExpenseStore((s) => s.actions.setCronExpression); return ( - - -
- - -
+ + ); }; diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 369356ea..2204e5a3 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -2,6 +2,12 @@ import cronstrue from 'cronstrue'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ToggleGroup, ToggleGroupItem } from './toggle-group'; +import { Label } from './label'; +import { Input } from './input'; +import { Button } from './button'; +import { cn } from '~/lib/utils'; +import { format } from 'date-fns'; +import { TFunction, useTranslation } from 'next-i18next'; export interface CronTextResult { status: boolean; @@ -10,7 +16,9 @@ export interface CronTextResult { export function getCronText(cronString: string): CronTextResult { try { - const value = cronstrue.toString(cronString.trim()); + const value = cronstrue.toString(cronString.trim(), { + use24HourTimeFormat: true, + }); return { status: true, value }; } catch (error) { return { status: false }; @@ -19,7 +27,7 @@ export function getCronText(cronString: string): CronTextResult { export interface CronBuilderProps { onChange: (cronExpression: string) => void; - defaultValue?: string; + value: string; className?: string; } @@ -35,57 +43,31 @@ interface ParsedCron { }; } -const SCHEDULE_TYPES = [ - { value: 'never', label: 'Never' }, - { value: 'hour', label: 'Hourly' }, - { value: 'day', label: 'Daily' }, - { value: 'week', label: 'Weekly' }, - { value: 'month', label: 'Monthly' }, - { value: 'year', label: 'Yearly' }, - { value: 'custom', label: 'Custom' }, -] as const; +const SCHEDULE_TYPES = (t: TFunction) => + [ + { value: 'never', label: t('recurrence.schedule_type.never') }, + { value: 'day', label: t('recurrence.schedule_type.day') }, + { value: 'week', label: t('recurrence.schedule_type.week') }, + { value: 'month', label: t('recurrence.schedule_type.month') }, + { value: 'year', label: t('recurrence.schedule_type.year') }, + { value: 'custom', label: t('recurrence.schedule_type.custom') }, + ] as const; -type ScheduleType = (typeof SCHEDULE_TYPES)[number]; +type ScheduleType = ReturnType[number]; -const MINUTES = Array.from({ length: 60 }, (_, i) => i); -const HOURS = Array.from({ length: 24 }, (_, i) => i); const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1); -const COMMON_MINUTES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]; -const MONTHS_SHORT = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; -const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - -// Enhanced button style utility with multiple states -const getButtonStyles = (state = 'default'): string => { - const base = 'px-2 py-1 text-xs rounded border transition-colors'; - - switch (state) { - case 'selected': - return `${base} bg-[hsl(var(--selected))] text-[hsl(var(--selected-foreground))] border-[hsl(var(--selected))]`; - case 'pressed': - return `${base} bg-[hsl(var(--pressed))] text-[hsl(var(--pressed-foreground))] border-[hsl(var(--pressed))]`; - case 'disabled': - return `${base} bg-[hsl(var(--disabled))] text-[hsl(var(--disabled-foreground))] border-[hsl(var(--disabled))] cursor-not-allowed`; - case 'loading': - return `${base} bg-[hsl(var(--processing))] text-[hsl(var(--processing-foreground))] border-[hsl(var(--processing))]`; - case 'focused': - return `${base} bg-[hsl(var(--focused))] text-[hsl(var(--focused-foreground))] border-[hsl(var(--focused))] outline-none ring-2 ring-[hsl(var(--focused)/50%)]`; - default: - return `${base} bg-background text-foreground border-border hover:bg-[hsl(var(--selected))] hover:text-[hsl(var(--selected-foreground))] focus:bg-[hsl(var(--focused))] focus:text-[hsl(var(--focused-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--focused)/50%)] active:bg-[hsl(var(--pressed))] active:text-[hsl(var(--pressed-foreground))]`; - } -}; + +const MONTHS_SHORT = (code: string) => + Array.from({ length: 12 }, (_, i) => { + const date = new Date(2000, i, 1); // Year 2000, month i, day 1 + return new Intl.DateTimeFormat(code, { month: 'short' }).format(date); + }); + +const DAYS_SHORT = (code: string) => + Array.from({ length: 7 }, (_, i) => { + const date = new Date(2000, 0, 2 + i); // Jan 2, 2000 was a Sunday + return new Intl.DateTimeFormat(code, { weekday: 'short' }).format(date); + }); // GridButton component for reusable grid buttons interface GridButtonProps { @@ -108,25 +90,16 @@ const GridButton = React.memo( className = '', disabled = false, }) => { - const [isPressed, setIsPressed] = useState(false); - return ( - + ); }, ); @@ -139,8 +112,7 @@ interface ScheduleFieldsProps { renderDaysOfWeekList: () => React.ReactNode; renderMonthsGrid: () => React.ReactNode; renderDaysOfMonthGrid: () => React.ReactNode; - renderHoursGrid: () => React.ReactNode; - renderMinutesGrid: () => React.ReactNode; + renderTimeInput: () => React.ReactNode; } const ScheduleFields = React.memo( @@ -149,65 +121,35 @@ const ScheduleFields = React.memo( renderDaysOfWeekList, renderMonthsGrid, renderDaysOfMonthGrid, - renderHoursGrid, - renderMinutesGrid, + renderTimeInput, }) => { - if (scheduleType === 'week') { - return ( -
-
{renderDaysOfWeekList()}
-
- {renderHoursGrid()} - {renderMinutesGrid()} -
-
- ); - } - - if (scheduleType === 'year') { - return ( -
-
{renderMonthsGrid()}
-
{renderDaysOfMonthGrid()}
-
{renderHoursGrid()}
-
{renderMinutesGrid()}
-
- ); - } - - if (scheduleType === 'month') { - return ( -
-
{renderDaysOfMonthGrid()}
-
- {renderHoursGrid()} - {renderMinutesGrid()} -
-
- ); - } + const outputs = [renderTimeInput]; - if (scheduleType === 'day') { - return ( -
- {renderHoursGrid()} - {renderMinutesGrid()} -
- ); + if (scheduleType === 'never') { + return null; } - if (scheduleType === 'hour') { - return
{renderMinutesGrid()}
; + if (scheduleType === 'week') { + outputs.push(renderDaysOfWeekList); + } else if (scheduleType === 'year') { + outputs.push(renderMonthsGrid); + outputs.push(renderDaysOfMonthGrid); + } else if (scheduleType === 'month') { + outputs.push(renderDaysOfMonthGrid); } - return null; + return outputs.map((RenderFunc, index) => ( + {RenderFunc()} + )); }, ); ScheduleFields.displayName = 'ScheduleFields'; -export function CronBuilder({ onChange, defaultValue, className }: CronBuilderProps) { - const defaultSchedule = defaultValue || '0 0 * * 0'; // Use provided default or fallback +export function CronBuilder({ onChange, value, className }: CronBuilderProps) { + const { t, i18n } = useTranslation(); + + const defaultSchedule = value; // Use provided default or fallback // Helper function to parse cron expression and determine schedule type const parseCronExpression = (cronExpr: string): ParsedCron => { @@ -278,7 +220,6 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr const [daysOfWeek, setDaysOfWeek] = useState(initialParsed.values.daysOfWeek || [0]); const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); const [cronExpression, setCronExpression] = useState(defaultSchedule); - const [showAllMinutes, setShowAllMinutes] = useState(false); function loadDefaults() { setMinutes([0]); @@ -305,20 +246,17 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr const minutesCSV = cleanMinutes.length === 60 ? '*' : cleanMinutes.join(','); switch (scheduleType) { - case 'hour': - expression = `${minutesCSV} * * * ?`; - break; case 'day': - expression = `${minutesCSV} ${hoursCSV} * * ?`; + expression = `${minutesCSV} ${hoursCSV} * * *`; break; case 'week': - expression = `${minutesCSV} ${hoursCSV} ? * ${dowCSV}`; + expression = `${minutesCSV} ${hoursCSV} * * ${dowCSV}`; break; case 'month': - expression = `${minutesCSV} ${hoursCSV} ${domCSV} * ?`; + expression = `${minutesCSV} ${hoursCSV} ${domCSV} * *`; break; case 'year': - expression = `${minutesCSV} ${hoursCSV} ${domCSV} ${monthsCSV} ?`; + expression = `${minutesCSV} ${hoursCSV} ${domCSV} ${monthsCSV} *`; break; case 'custom': expression = custom || ''; @@ -353,24 +291,15 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr } const MonthButton = React.memo(({ month, index, isSelected, onClick }) => { - const [isPressed, setIsPressed] = useState(false); - return ( - + ); }); @@ -378,9 +307,9 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr const renderMonthsGrid = () => (
- +
- {MONTHS_SHORT.map((month, index) => ( + {MONTHS_SHORT(i18n.language).map((month, index) => ( ( ({ day, index, isSelected, isWeekend, onClick }) => { - const [isPressed, setIsPressed] = useState(false); - return ( - + ); }, ); @@ -441,9 +366,9 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr return (
- +
- {DAYS_SHORT.map((day, index) => { + {DAYS_SHORT(i18n.language).map((day, index) => { const isWeekend = weekendDays.includes(index); const isSelected = daysOfWeek.includes(index); return ( @@ -471,7 +396,7 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr const renderDaysOfMonthGrid = () => (
- +
{DAYS_OF_MONTH.map((day) => ( ); - const handleHourToggle = useCallback((hour: number | string) => { - const hourNum = typeof hour === 'number' ? hour : parseInt(hour, 10); - setHours((prev) => - prev.includes(hourNum) ? prev.filter((h) => h !== hourNum) : [...prev, hourNum], + const renderTimeInput = () => { + const [value, setValue] = useState( + format(new Date().setHours(hours[0] || 0, minutes[0] || 0), 'HH:mm'), ); - }, []); - const renderHoursGrid = () => ( -
- -
- {HOURS.map((hour) => ( - - ))} + return ( +
+ + { + const date = e.target.valueAsDate; + setValue(e.target.value); + if (date) { + setHours([date.getHours()]); + setMinutes([date.getMinutes()]); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + />
-
- ); - - const handleMinuteToggle = useCallback((minute: number | string) => { - const minuteNum = typeof minute === 'number' ? minute : parseInt(minute, 10); - setMinutes((prev) => - prev.includes(minuteNum) ? prev.filter((m) => m !== minuteNum) : [...prev, minuteNum], ); - }, []); - - const minutesToShow = useMemo( - () => (showAllMinutes ? MINUTES : COMMON_MINUTES), - [showAllMinutes], - ); - - const renderMinutesGrid = () => ( -
-
- - ({showAllMinutes ? 'All' : 'Common'}) - -
-
- {minutesToShow.map((minute) => ( - - ))} -
-
- ); + }; // Render Cron expression builder UI return ( @@ -563,7 +454,7 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr }} className="w-fit justify-start" > - {SCHEDULE_TYPES.map(({ value, label }) => ( + {SCHEDULE_TYPES(t).map(({ value, label }) => ( {label} @@ -572,17 +463,17 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr
{scheduleType === 'never' ? ( -
No schedule configured
+
{t('recurrence.never')}
) : (
{scheduleType === 'custom' ? (
- + {t('recurrence.cron_expression')} + )} @@ -619,8 +509,8 @@ export function CronBuilder({ onChange, defaultValue, className }: CronBuilderPr ); else return ( -

- Invalid cron expression +

+ {t('errors.invalid_cron_expression')}

); })()} diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index 27ad3e67..8f37d137 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -1,5 +1,3 @@ -import { RecurrenceInterval } from '@prisma/client'; -import { getDate, getMonth } from 'date-fns'; import { db } from '~/server/db'; export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'monthly') => { @@ -23,18 +21,7 @@ export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'm } }; -export const createRecurringExpenseJob = async ( - expenseId: string, - date: Date, - repeatEvery: number, - repeatInterval: RecurrenceInterval, -) => { - // Implementation for creating a recurring expense job using pg_cron - const cronExpression = getCronExpression(date, repeatEvery, repeatInterval); - - // oxlint-disable-next-line no-unused-vars - const procedure = ''; - +export const createRecurringExpenseJob = async (expenseId: string, cronExpression: string) => { await db.$executeRaw` SELECT cron.schedule( ${expenseId}, @@ -42,21 +29,3 @@ SELECT cron.schedule( $$ SELECT duplicate_expense_with_participants(${expenseId}::UUID); $$ );`; }; - -const getCronExpression = (date: Date, repeatEvery: number, repeatInterval: RecurrenceInterval) => { - switch (repeatInterval) { - case RecurrenceInterval.DAILY: - case RecurrenceInterval.WEEKLY: { - const mult = repeatInterval === RecurrenceInterval.WEEKLY ? 7 : 1; - return `0 0 ${getDate(date)}/${mult * repeatEvery} * *`; - } - case RecurrenceInterval.MONTHLY: - case RecurrenceInterval.YEARLY: { - const dayOfMonth = getDate(date); - const mult = repeatInterval === RecurrenceInterval.YEARLY ? 12 : 1; - return `0 0 ${dayOfMonth > 28 ? 'L' : dayOfMonth} ${getMonth(date) + 1}/${mult * repeatEvery} *`; - } - default: - throw new Error('Invalid recurrence interval'); - } -}; diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 1ee947b2..96755dd5 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -31,8 +31,7 @@ export interface AddExpenseState { canSplitScreenClosed: boolean; splitScreenOpen: boolean; expenseDate: Date; - repeatInterval?: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; - repeatEvery: number; + cronExpression: string; transactionId?: string; multipleTransactions: TransactionAddInputModel[]; isTransactionLoading: boolean; @@ -60,9 +59,7 @@ export interface AddExpenseState { setTransactionId: (transactionId?: string) => void; setMultipleTransactions: (multipleTransactions: TransactionAddInputModel[]) => void; setIsTransactionLoading: (isTransactionLoading: boolean) => void; - setRepeatInterval: (repeatInterval: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY') => void; - setRepeatEvery: (repeatEvery: number) => void; - unsetRepeat: () => void; + setCronExpression: (cronExpression: string) => void; }; } @@ -92,6 +89,7 @@ export const useAddExpenseStore = create()((set) => ({ repeatEvery: 1, multipleTransactions: [], isTransactionLoading: false, + cronExpression: '', actions: { setAmount: (realAmount) => set((s) => { @@ -280,6 +278,16 @@ export const useAddExpenseStore = create()((set) => ({ group: undefined, amountStr: '', splitShares: s.currentUser ? { [s.currentUser.id]: initSplitShares() } : {}, + isNegative: false, + canSplitScreenClosed: true, + splitScreenOpen: false, + expenseDate: new Date(), + transactionId: undefined, + multipleTransactions: [], + isTransactionLoading: false, + cronExpression: '', + isFileUploading: false, + paidBy: s.currentUser, })); }, setSplitScreenOpen: (splitScreenOpen) => set({ splitScreenOpen }), @@ -287,9 +295,7 @@ export const useAddExpenseStore = create()((set) => ({ setTransactionId: (transactionId) => set({ transactionId }), setMultipleTransactions: (multipleTransactions) => set({ multipleTransactions }), setIsTransactionLoading: (isTransactionLoading) => set({ isTransactionLoading }), - setRepeatInterval: (repeatInterval) => set({ repeatInterval }), - setRepeatEvery: (repeatEvery) => set({ repeatEvery }), - unsetRepeat: () => set({ repeatInterval: undefined, repeatEvery: 1 }), + setCronExpression: (cronExpression) => set({ cronExpression }), }, })); From 74586c341102a09fb1ea4976778d23cded0a4b1f Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 22:37:32 +0200 Subject: [PATCH 31/43] Add last day cron key and fix drawer height --- src/components/AddExpense/RecurrenceInput.tsx | 1 + src/components/ui/cron-builder.tsx | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/AddExpense/RecurrenceInput.tsx b/src/components/AddExpense/RecurrenceInput.tsx index 6ba9b854..38fc1083 100644 --- a/src/components/AddExpense/RecurrenceInput.tsx +++ b/src/components/AddExpense/RecurrenceInput.tsx @@ -16,6 +16,7 @@ export const RecurrenceInput: React.FC> = ({ childre trigger={children} shouldCloseOnAction actionTitle={t('actions.confirm')} + className="h-[70vh]" > diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 2204e5a3..e2313f8e 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -67,7 +67,7 @@ const DAYS_SHORT = (code: string) => Array.from({ length: 7 }, (_, i) => { const date = new Date(2000, 0, 2 + i); // Jan 2, 2000 was a Sunday return new Intl.DateTimeFormat(code, { weekday: 'short' }).format(date); - }); + }).concat('L'); // GridButton component for reusable grid buttons interface GridButtonProps { @@ -215,9 +215,13 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const [scheduleType, setScheduleType] = useState(initialParsed.type); const [minutes, setMinutes] = useState(initialParsed.values.minutes || [0]); const [hours, setHours] = useState(initialParsed.values.hours || [0]); - const [daysOfMonth, setDaysOfMonth] = useState(initialParsed.values.daysOfMonth || [1]); + const [daysOfMonth, setDaysOfMonth] = useState>( + initialParsed.values.daysOfMonth || [1], + ); const [months, setMonths] = useState(initialParsed.values.months || [1]); - const [daysOfWeek, setDaysOfWeek] = useState(initialParsed.values.daysOfWeek || [0]); + const [daysOfWeek, setDaysOfWeek] = useState>( + initialParsed.values.daysOfWeek || [0], + ); const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); const [cronExpression, setCronExpression] = useState(defaultSchedule); @@ -323,7 +327,8 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { ); const handleDayOfWeekToggle = useCallback((dayIndex: number | string) => { - const dayNum = typeof dayIndex === 'number' ? dayIndex : parseInt(dayIndex, 10); + const dayNum = + typeof dayIndex === 'number' || dayIndex === 'L' ? dayIndex : parseInt(dayIndex, 10); setDaysOfWeek((prev) => prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], ); @@ -367,7 +372,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { return (
-
+
{DAYS_SHORT(i18n.language).map((day, index) => { const isWeekend = weekendDays.includes(index); const isSelected = daysOfWeek.includes(index); @@ -388,7 +393,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { }; const handleDayOfMonthToggle = useCallback((day: number | string) => { - const dayNum = typeof day === 'number' ? day : parseInt(day, 10); + const dayNum = typeof day === 'number' || day === 'L' ? day : parseInt(day, 10); setDaysOfMonth((prev) => prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], ); @@ -406,6 +411,12 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { onClick={handleDayOfMonthToggle} /> ))} +
); @@ -452,7 +463,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { setScheduleType(value); } }} - className="w-fit justify-start" + className="w-fit flex-wrap justify-start" > {SCHEDULE_TYPES(t).map(({ value, label }) => ( From 8144fb4c344c73770185900262ee871a0f1d03d4 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 23:06:03 +0200 Subject: [PATCH 32/43] Remove redundant createdById from recurrence --- .../20250920192654_recurrence/migration.sql | 4 ---- prisma/schema.prisma | 15 ++++++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql index cd8afe8d..1dc3a766 100644 --- a/prisma/migrations/20250920192654_recurrence/migration.sql +++ b/prisma/migrations/20250920192654_recurrence/migration.sql @@ -5,7 +5,6 @@ ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; CREATE TABLE "public"."ExpenseRecurrence" ( "id" SERIAL NOT NULL, "expenseId" TEXT NOT NULL, - "createdById" INTEGER NOT NULL, "jobId" BIGINT NOT NULL, "notified" BOOLEAN NOT NULL DEFAULT true, @@ -18,9 +17,6 @@ CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence -- CreateIndex CREATE UNIQUE INDEX "ExpenseRecurrence_expenseId_key" ON "public"."ExpenseRecurrence"("expenseId"); --- AddForeignKey -ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfafb643..b0a50b11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,7 +57,6 @@ model User { groups Group[] associatedGroups GroupUser[] expenseParticipants ExpenseParticipant[] - createdRecurrences ExpenseRecurrence[] expenseNotes ExpenseNote[] userBalances Balance[] @relation(name: "UserBalance") cachedBankData CachedBankData[] @relation(name: "UserCachedBankData") @@ -225,14 +224,12 @@ model ExpenseNote { } model ExpenseRecurrence { - id Int @id @default(autoincrement()) - createdById Int - jobId BigInt @unique - notified Boolean @default(true) - expenseId String @unique @db.Uuid - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) - job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) + id Int @id @default(autoincrement()) + jobId BigInt @unique + notified Boolean @default(true) + expenseId String @unique @db.Uuid + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) @@schema("public") } From 75b19af0265e3741cc9b287b0598c79da48233a5 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 6 Oct 2025 23:06:29 +0200 Subject: [PATCH 33/43] Basic recurrent expense submission --- src/components/AddExpense/AddExpensePage.tsx | 2 ++ src/components/ui/cron-builder.tsx | 2 +- src/server/api/routers/expense.ts | 18 ++++++++++++++++++ src/server/api/services/scheduleService.ts | 13 +++++++------ src/types/expense.types.ts | 2 +- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index b865fbd3..1ccadbf1 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -122,6 +122,7 @@ export const AddOrEditExpensePage: React.FC<{ expenseDate, expenseId, transactionId, + cronExpression, }, { onSuccess: (d) => { @@ -164,6 +165,7 @@ export const AddOrEditExpensePage: React.FC<{ setMultipleTransactions, transactionId, setIsTransactionLoading, + cronExpression, ]); const handleDescriptionChange = useCallback( diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index e2313f8e..06ca3701 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -67,7 +67,7 @@ const DAYS_SHORT = (code: string) => Array.from({ length: 7 }, (_, i) => { const date = new Date(2000, 0, 2 + i); // Jan 2, 2000 was a Sunday return new Intl.DateTimeFormat(code, { weekday: 'short' }).format(date); - }).concat('L'); + }); // GridButton component for reusable grid buttons interface GridButtonProps { diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 05e13891..bc78d73e 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -19,6 +19,7 @@ import { currencyRateProvider } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; +import { createRecurringExpenseJob } from '../services/scheduleService'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -106,6 +107,23 @@ export const expenseRouter = createTRPCRouter({ ? await editExpense(input, ctx.session.user.id) : await createExpense(input, ctx.session.user.id); + if (expense && input.cronExpression) { + const [{ schedule }] = await createRecurringExpenseJob(expense.id, input.cronExpression); + console.log('Created recurring expense job with jobid:', schedule); + await db.expense.update({ + where: { id: expense.id }, + data: { + recurrence: { + create: { + job: { + connect: { jobid: schedule }, + }, + }, + }, + }, + }); + } + return expense; } catch (error) { console.error(error); diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index 8f37d137..6cceb1a5 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -21,11 +21,12 @@ export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'm } }; -export const createRecurringExpenseJob = async (expenseId: string, cronExpression: string) => { - await db.$executeRaw` +export const createRecurringExpenseJob = async ( + expenseId: string, + cronExpression: string, +) => db.$queryRaw<[{ schedule: bigint }]>` SELECT cron.schedule( - ${expenseId}, - ${cronExpression}, - $$ SELECT duplicate_expense_with_participants(${expenseId}::UUID); $$ +${expenseId}, +${cronExpression.replaceAll('L', '$')}, +$$ SELECT duplicate_expense_with_participants(${expenseId}::UUID); $$ );`; -}; diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 6c3f333b..c9f7bcb5 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -21,7 +21,6 @@ export type CreateExpense = Omit< expenseId?: string; transactionId?: string; otherConversion?: string; - recurrenceId?: string; participants: Omit[]; }; @@ -47,6 +46,7 @@ export const createExpenseSchema = z.object({ expenseDate: z.date().optional(), expenseId: z.string().optional(), otherConversion: z.string().optional(), + cronExpression: z.string().optional(), }) satisfies z.ZodType; export const createCurrencyConversionSchema = z.object({ From 303dd994f01654ba79238b23db3046fe368d365d Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 18:16:02 +0200 Subject: [PATCH 34/43] One more migration update, change relation to 1-N for expenseRecurrence --- .../migration.sql | 0 .../20250920192654_recurrence/migration.sql | 6 +--- .../migration.sql | 13 -------- .../migration.sql | 23 ------------- prisma/schema.prisma | 13 ++++---- .../api/services/notificationService.ts | 32 +++++++++++-------- 6 files changed, 26 insertions(+), 61 deletions(-) rename prisma/migrations/{202509052609583_add_gocardless_bank_transaction_integration => 20250907160149_add_gocardless_bank_transaction_integration}/migration.sql (100%) diff --git a/prisma/migrations/202509052609583_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql similarity index 100% rename from prisma/migrations/202509052609583_add_gocardless_bank_transaction_integration/migration.sql rename to prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql index 1dc3a766..db3c5968 100644 --- a/prisma/migrations/20250920192654_recurrence/migration.sql +++ b/prisma/migrations/20250920192654_recurrence/migration.sql @@ -4,7 +4,6 @@ ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; -- CreateTable CREATE TABLE "public"."ExpenseRecurrence" ( "id" SERIAL NOT NULL, - "expenseId" TEXT NOT NULL, "jobId" BIGINT NOT NULL, "notified" BOOLEAN NOT NULL DEFAULT true, @@ -14,11 +13,8 @@ CREATE TABLE "public"."ExpenseRecurrence" ( -- CreateIndex CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence"("jobId"); --- CreateIndex -CREATE UNIQUE INDEX "ExpenseRecurrence_expenseId_key" ON "public"."ExpenseRecurrence"("expenseId"); - -- AddForeignKey -ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_recurrenceId_fkey" FOREIGN KEY ("recurrenceId") REFERENCES "public"."ExpenseRecurrence"("id") ON DELETE CASCADE ON UPDATE CASCADE; CREATE OR REPLACE FUNCTION duplicate_expense_with_participants(original_expense_id UUID) RETURNS UUID AS $$ diff --git a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql index 86d60108..c64c2e3b 100644 --- a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql +++ b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql @@ -3,7 +3,6 @@ - The `otherConversion` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column. - A unique constraint covering the columns `[uuidId]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. - - A unique constraint covering the columns `[expenseUuid]` on the table `ExpenseRecurrence` will be added. If there are existing duplicate values, this will fail. */ -- DropForeignKey @@ -15,9 +14,6 @@ ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseId_fkey"; -- DropForeignKey ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseId_fkey"; --- DropForeignKey -ALTER TABLE "public"."ExpenseRecurrence" DROP CONSTRAINT "ExpenseRecurrence_expenseId_fkey"; - -- AlterTable ALTER TABLE "public"."Expense" ADD COLUMN "uuidId" UUID DEFAULT gen_random_uuid(), DROP COLUMN "otherConversion", @@ -29,18 +25,12 @@ ALTER TABLE "public"."ExpenseNote" ADD COLUMN "expenseUuid" UUID; -- AlterTable ALTER TABLE "public"."ExpenseParticipant" ADD COLUMN "expenseUuid" UUID; --- AlterTable -ALTER TABLE "public"."ExpenseRecurrence" ADD COLUMN "expenseUuid" UUID; - -- CreateIndex CREATE UNIQUE INDEX "Expense_uuidId_key" ON "public"."Expense"("uuidId"); -- CreateIndex CREATE UNIQUE INDEX "Expense_otherConversion_key" ON "public"."Expense"("otherConversion"); --- CreateIndex -CREATE UNIQUE INDEX "ExpenseRecurrence_expenseUuid_key" ON "public"."ExpenseRecurrence"("expenseUuid"); - -- AddForeignKey ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; @@ -50,9 +40,6 @@ ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_exp -- AddForeignKey ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; - -- Migrate data UPDATE "public"."Expense" SET "uuidId" = gen_random_uuid() WHERE "uuidId" IS NULL; UPDATE "public"."ExpenseParticipant" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL; diff --git a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql index 24b2ed5a..dc001d07 100644 --- a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql +++ b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql @@ -7,10 +7,8 @@ - You are about to drop the column `expenseUuid` on the `ExpenseNote` table. All the data in the column will be lost. - The primary key for the `ExpenseParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint. - You are about to drop the column `expenseUuid` on the `ExpenseParticipant` table. All the data in the column will be lost. - - You are about to drop the column `expenseUuid` on the `ExpenseRecurrence` table. All the data in the column will be lost. - Changed the type of `expenseId` on the `ExpenseNote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - Changed the type of `expenseId` on the `ExpenseParticipant` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - - Changed the type of `expenseId` on the `ExpenseRecurrence` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. */ -- DropForeignKey @@ -22,15 +20,9 @@ ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseUuid_fkey -- DropForeignKey ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseUuid_fkey"; --- DropForeignKey -ALTER TABLE "public"."ExpenseRecurrence" DROP CONSTRAINT "ExpenseRecurrence_expenseUuid_fkey"; - -- DropIndex DROP INDEX "public"."Expense_uuidId_key"; --- DropIndex -DROP INDEX "public"."ExpenseRecurrence_expenseUuid_key"; - -- AlterTable: Expense - Drop primary key constraint first ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_pkey"; @@ -61,21 +53,9 @@ ALTER TABLE "public"."ExpenseParticipant" RENAME COLUMN "expenseUuid" TO "expens -- AlterTable: ExpenseParticipant - Add primary key constraint ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_pkey" PRIMARY KEY ("expenseId", "userId"); --- AlterTable: ExpenseRecurrence - Drop old expenseId column -ALTER TABLE "public"."ExpenseRecurrence" DROP COLUMN "expenseId"; - --- AlterTable: ExpenseRecurrence - Rename expenseUuid to expenseId -ALTER TABLE "public"."ExpenseRecurrence" RENAME COLUMN "expenseUuid" TO "expenseId"; - -- AlterTable ALTER TABLE "public"."ExpenseNote" ALTER COLUMN "expenseId" SET NOT NULL; --- AlterTable -ALTER TABLE "public"."ExpenseRecurrence" ALTER COLUMN "expenseId" SET NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "ExpenseRecurrence_expenseId_key" ON "public"."ExpenseRecurrence"("expenseId"); - -- AddForeignKey ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -84,6 +64,3 @@ ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_exp -- AddForeignKey ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0a50b11..a5e68d40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -190,7 +190,7 @@ model Expense { updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade) conversionFrom Expense? @relation(name: "CurrencyConversion") - recurrence ExpenseRecurrence? + recurrence ExpenseRecurrence? @relation(fields: [recurrenceId], references: [id], onDelete: Cascade) expenseParticipants ExpenseParticipant[] expenseNotes ExpenseNote[] transactionId String? @@ -224,12 +224,11 @@ model ExpenseNote { } model ExpenseRecurrence { - id Int @id @default(autoincrement()) - jobId BigInt @unique - notified Boolean @default(true) - expenseId String @unique @db.Uuid - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) - job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) + id Int @id @default(autoincrement()) + jobId BigInt @unique + notified Boolean @default(true) + expense Expense[] + job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) @@schema("public") } diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts index 66a48d4b..d270d039 100644 --- a/src/server/api/services/notificationService.ts +++ b/src/server/api/services/notificationService.ts @@ -108,23 +108,29 @@ export async function checkRecurrenceNotifications() { notified: true, }, }, - select: { - expenseId: true, + include: { + expense: { + select: { id: true }, + orderBy: { createdAt: 'desc' }, + take: 1, + }, }, }); await Promise.all( - recurrences.map(async (r) => { - await sendExpensePushNotification(r.expenseId); - await db.expenseRecurrence.update({ - where: { - expenseId: r.expenseId, - }, - data: { - notified: true, - }, - }); - }), + recurrences + .filter((r) => r.expense[0]) + .map(async (r) => { + await sendExpensePushNotification(r.expense[0]!.id); + await db.expenseRecurrence.update({ + where: { + id: r.id, + }, + data: { + notified: true, + }, + }); + }), ); } catch (e) { console.error('Error sending recurrence notifications', e); From f4bc0ace18dd064d9f027a837cab0ea96d813b4b Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 18:46:44 +0200 Subject: [PATCH 35/43] Localize and display recurrence in details --- public/locales/en/common.json | 1 + src/components/Expense/ExpenseDetails.tsx | 21 ++++++-- src/components/ui/cron-builder.tsx | 61 +++++++++++++---------- src/hooks/useIntlCronParser.ts | 12 +++++ src/pages/add.tsx | 5 ++ src/server/api/routers/expense.ts | 13 +++++ 6 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 src/hooks/useIntlCronParser.ts diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4114719c..bb1e7403 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -314,6 +314,7 @@ "days_of_month": "Days of Month", "months": "Months", "never": "No schedule configured", + "recurring": "Recurring", "schedule_type": { "never": "Never", "custom": "Custom", diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index bafd3812..964f07bd 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -4,8 +4,9 @@ import { type User as NextUser } from 'next-auth'; import { toUIString } from '~/utils/numbers'; import type { inferRouterOutputs } from '@trpc/server'; -import { PencilIcon } from 'lucide-react'; +import { Landmark, PencilIcon } from 'lucide-react'; import React, { type ComponentProps, useCallback } from 'react'; +import { useIntlCronParser } from '~/hooks/useIntlCronParser'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { isCurrencyCode } from '~/lib/currency'; import type { ExpenseRouter } from '~/server/api/routers/expense'; @@ -17,7 +18,6 @@ import { Button } from '../ui/button'; import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; -import { Landmark } from 'lucide-react'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -28,7 +28,9 @@ interface ExpenseDetailsProps { } const ExpenseDetails: React.FC = ({ user, expense, storagePublicUrl }) => { - const { displayName, toUIDate, t } = useTranslationWithUtils(); + const { displayName, toUIDate, t, i18n } = useTranslationWithUtils(); + + const { cronParser, i18nReady } = useIntlCronParser(); return ( <> @@ -67,6 +69,19 @@ const ExpenseDetails: React.FC = ({ user, expense, storageP {t('ui.on')} {toUIDate(expense.createdAt, { year: true })}

)} + {expense.recurrence ? ( +

+ {t('recurrence.recurring')} + {i18nReady + ? `: + + ${cronParser.toString(expense.recurrence.job.schedule, { + use24HourTimeFormat: true, + locale: i18n.language.split('-')[0], + })}` + : ''} +

+ ) : null}
diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 06ca3701..ac94f7b0 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -1,5 +1,4 @@ // copied from: https://github.com/vpfaiz/cron-builder-ui/ -import cronstrue from 'cronstrue'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ToggleGroup, ToggleGroupItem } from './toggle-group'; import { Label } from './label'; @@ -8,16 +7,18 @@ import { Button } from './button'; import { cn } from '~/lib/utils'; import { format } from 'date-fns'; import { TFunction, useTranslation } from 'next-i18next'; +import { useIntlCronParser } from '~/hooks/useIntlCronParser'; export interface CronTextResult { status: boolean; value?: string; } -export function getCronText(cronString: string): CronTextResult { +export function getCronText(cronParser: any, cronString: string, locale?: string): CronTextResult { try { - const value = cronstrue.toString(cronString.trim(), { + const value = cronParser.toString(cronString.trim(), { use24HourTimeFormat: true, + locale, }); return { status: true, value }; } catch (error) { @@ -164,7 +165,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { // Helper to safely parse numeric values from cron parts const parseNumbers = (part?: string): number[] => { - if (part === '*' || part === '?' || !part) return []; + if (part === '*' || !part) return []; return part .split(',') .map((v) => parseInt(v.trim(), 10)) @@ -173,9 +174,9 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { try { // Check for standard patterns - if (min !== '*' && hour === '*' && dom === '*' && month === '*' && dow === '?') { + if (min !== '*' && hour === '*' && dom === '*' && month === '*' && dow === '*') { return { type: 'hour', values: { minutes: parseNumbers(min) } }; - } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow === '?') { + } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow === '*') { return { type: 'day', values: { @@ -183,7 +184,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { hours: parseNumbers(hour), }, }; - } else if (min !== '*' && hour !== '*' && dom === '?' && month === '*' && dow !== '*') { + } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow !== '*') { return { type: 'week', values: { @@ -192,7 +193,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { daysOfWeek: parseNumbers(dow), }, }; - } else if (min !== '*' && hour !== '*' && dom !== '*' && month === '*' && dow === '?') { + } else if (min !== '*' && hour !== '*' && dom !== '*' && month === '*' && dow === '*') { return { type: 'month', values: { @@ -225,6 +226,8 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); const [cronExpression, setCronExpression] = useState(defaultSchedule); + const { cronParser } = useIntlCronParser(); + function loadDefaults() { setMinutes([0]); setHours([0]); @@ -269,7 +272,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { expression = ''; } - if (getCronText(expression).status) { + if (getCronText(cronParser, expression).status) { setCronExpression(expression); onChange(expression); } else { @@ -507,24 +510,30 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { /> )} - {(() => { - const cronString = getCronText(cronExpression); - if (cronString.status) - return ( -

- {cronString.value}{' '} - - cron({cronExpression}) - -

- ); - else - return ( -

- {t('errors.invalid_cron_expression')} -

+
+ {(() => { + const cronString = getCronText( + cronParser, + cronExpression, + i18n.language.split('-')[0], ); - })()} + if (cronString.status) + return ( +

+ {cronString.value}{' '} + + cron({cronExpression}) + +

+ ); + else + return ( +

+ {t('errors.invalid_cron_expression')} +

+ ); + })()} +
)}
diff --git a/src/hooks/useIntlCronParser.ts b/src/hooks/useIntlCronParser.ts new file mode 100644 index 00000000..e10c4267 --- /dev/null +++ b/src/hooks/useIntlCronParser.ts @@ -0,0 +1,12 @@ +import React from 'react'; +import cronstrue from 'cronstrue'; + +export const useIntlCronParser = () => { + const [cronParser, setCronParser] = React.useState(null); + + React.useEffect(() => { + void import('cronstrue/i18n').then((mod) => setCronParser(mod)); + }, []); + + return { cronParser: cronParser || cronstrue, i18nReady: !!cronParser }; +}; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index c583f43c..7a2da1a8 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -32,6 +32,7 @@ const AddPage: NextPageWithUser<{ setExpenseDate, setCategory, resetState, + setCronExpression, } = useAddExpenseStore((s) => s.actions); const currentUser = useAddExpenseStore((s) => s.currentUser); @@ -115,6 +116,9 @@ const AddPage: NextPageWithUser<{ ); useAddExpenseStore.setState({ showFriends: false }); setExpenseDate(expenseQuery.data.expenseDate); + if (expenseQuery.data.recurrence) { + setCronExpression(expenseQuery.data.recurrence.job.schedule); + } }, [ _expenseId, expenseQuery.data, @@ -127,6 +131,7 @@ const AddPage: NextPageWithUser<{ setGroup, setPaidBy, setParticipants, + setCronExpression, ]); return ( diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index bc78d73e..4038a296 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -331,6 +331,15 @@ export const expenseRouter = createTRPCRouter({ deletedByUser: true, updatedByUser: true, group: true, + recurrence: { + include: { + job: { + select: { + schedule: true, + }, + }, + }, + }, conversionTo: { include: { expenseParticipants: { @@ -371,6 +380,10 @@ export const expenseRouter = createTRPCRouter({ }); } + if (expense?.recurrence?.job.schedule) { + expense.recurrence.job.schedule = expense.recurrence.job.schedule.replaceAll('$', 'L'); + } + return expense; }), From ab455aeb94348bd2b998cce165c4468d5ab49caa Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 19:40:48 +0200 Subject: [PATCH 36/43] Display recurring expenses in activity subpage --- public/locales/en/common.json | 3 ++ src/pages/activity.tsx | 2 +- src/pages/recurring.tsx | 46 +++++++++++++++++++++++++++---- src/server/api/routers/expense.ts | 32 ++++++++++----------- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index bb1e7403..ca64e2db 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -302,6 +302,7 @@ "activity": "Activity", "add": "Add", "add_expense": "Add Expense", + "recurring": "Recurring", "balances": "Balances", "groups": "Groups" }, @@ -314,7 +315,9 @@ "days_of_month": "Days of Month", "months": "Months", "never": "No schedule configured", + "empty": "No recurring expenses yet", "recurring": "Recurring", + "expense_for_the_amount_of": "Expense {{name}} for the amount of {{amount}} {{currency}}", "schedule_type": { "never": "Never", "custom": "Custom", diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx index dfe26b69..d2eff99a 100644 --- a/src/pages/activity.tsx +++ b/src/pages/activity.tsx @@ -55,7 +55,7 @@ const ActivityPage: NextPageWithUser = ({ user }) => { () => ( ), diff --git a/src/pages/recurring.tsx b/src/pages/recurring.tsx index 1ef5aa27..4b6c8069 100644 --- a/src/pages/recurring.tsx +++ b/src/pages/recurring.tsx @@ -1,34 +1,70 @@ +import { ChevronLeftIcon } from 'lucide-react'; import Head from 'next/head'; import Link from 'next/link'; import MainLayout from '~/components/Layout/MainLayout'; import { EntityAvatar } from '~/components/ui/avatar'; +import { Button } from '~/components/ui/button'; +import { useIntlCronParser } from '~/hooks/useIntlCronParser'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { withI18nStaticProps } from '~/utils/i18n/server'; +import { toUIString } from '~/utils/numbers'; -const RecurringPage: NextPageWithUser = ({ user }) => { - const { displayName, t, toUIDate } = useTranslationWithUtils(); +const RecurringPage: NextPageWithUser = () => { + const { t, toUIDate, i18n } = useTranslationWithUtils(); const recurringExpensesQuery = api.expense.getRecurringExpenses.useQuery(); + const { cronParser, i18nReady } = useIntlCronParser(); + return ( <> {t('navigation.recurring')} - + + + + +

{t('navigation.recurring')}

+
+ } + loading={recurringExpensesQuery.isPending} + >
{!recurringExpensesQuery.data?.length ? ( -
{t('ui.recurring.empty')}
+
{t('recurrence.empty')}
) : null} {recurringExpensesQuery.data?.map((e) => ( - +
+

+ {t('recurrence.expense_for_the_amount_of', { + name: e.expense.name, + amount: toUIString(e.expense.amount), + currency: e.expense.currency, + })} +

+

+ {t('recurrence.recurring')} + {i18nReady + ? `: + + ${cronParser.toString(e.job.schedule, { + use24HourTimeFormat: true, + locale: i18n.language.split('-')[0], + })}` + : ''} +

{toUIDate(e.expense.expenseDate)}

diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 4038a296..9a21bf52 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -425,24 +425,22 @@ export const expenseRouter = createTRPCRouter({ }), getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => { - const expenses = await db.expenseParticipant.findMany({ - where: { - userId: ctx.session.user.id, - expense: { - NOT: { - recurrenceId: null, - }, - }, - }, - orderBy: { - expense: { - createdAt: 'desc', - }, - }, + const recurrences = await db.expenseRecurrence.findMany({ include: { + job: true, expense: { + take: 1, + orderBy: { createdAt: 'desc' }, + where: { + deletedBy: null, + expenseParticipants: { + some: { + userId: ctx.session.user.id, + }, + }, + recurrenceId: { not: null }, + }, include: { - recurrence: true, addedByUser: { select: { name: true, @@ -456,7 +454,9 @@ export const expenseRouter = createTRPCRouter({ }, }); - return expenses; + return recurrences + .filter((r) => r.expense.length > 0) + .map((r) => ({ ...r, expense: r.expense[0]! })); }), getUploadUrl: protectedProcedure From e4f36521ccc4dfc545930e5280e843ca71c0a704 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 19:46:09 +0200 Subject: [PATCH 37/43] Use UTC hours/minutes in cron time input --- src/components/ui/cron-builder.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index ac94f7b0..8c6bf976 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -442,8 +442,8 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const date = e.target.valueAsDate; setValue(e.target.value); if (date) { - setHours([date.getHours()]); - setMinutes([date.getMinutes()]); + setHours([date.getUTCHours()]); + setMinutes([date.getUTCMinutes()]); } }} className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" From 1c6917b3152db54697ec18699eeceeb3fc95f64e Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 20:11:30 +0200 Subject: [PATCH 38/43] Update scheduling to work around sql injection prevention --- .../20250920192654_recurrence/migration.sql | 2 +- src/server/api/services/scheduleService.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql index db3c5968..205161db 100644 --- a/prisma/migrations/20250920192654_recurrence/migration.sql +++ b/prisma/migrations/20250920192654_recurrence/migration.sql @@ -45,7 +45,7 @@ BEGIN -- STEP 3: Set notified to false in the ExpenseRecurrence table UPDATE "ExpenseRecurrence" SET notified = false - WHERE expenseId = original_expense_id; + WHERE id = (SELECT "recurrenceId" FROM "Expense" WHERE id = original_expense_id); -- STEP 4: Return the new expense ID RETURN new_expense_id; diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index 6cceb1a5..2eb172eb 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -21,12 +21,9 @@ export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'm } }; -export const createRecurringExpenseJob = async ( - expenseId: string, - cronExpression: string, -) => db.$queryRaw<[{ schedule: bigint }]>` -SELECT cron.schedule( -${expenseId}, -${cronExpression.replaceAll('L', '$')}, -$$ SELECT duplicate_expense_with_participants(${expenseId}::UUID); $$ -);`; +export const createRecurringExpenseJob = async (expenseId: string, cronExpression: string) => + db.$queryRawUnsafe<[{ schedule: bigint }]>( + `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, + expenseId, + cronExpression.replaceAll('L', '$'), + ); From 44adc48f38656df00203c02f985ee0bdd99e7399 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 20:56:52 +0200 Subject: [PATCH 39/43] Use UTC for pg_cron --- .env.example | 1 - docker/dev/compose.yml | 2 +- docker/prod/compose.yml | 2 +- package.json | 1 + pnpm-lock.yaml | 34 ++++- src/components/AddExpense/AddExpensePage.tsx | 3 +- src/components/Expense/ExpenseDetails.tsx | 8 +- src/components/ui/cron-builder.tsx | 13 +- src/hooks/useIntlCronParser.ts | 16 ++- src/lib/cron.ts | 141 +++++++++++++++++++ src/pages/add.tsx | 9 +- src/pages/recurring.tsx | 8 +- src/server/api/services/scheduleService.ts | 2 +- 13 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 src/lib/cron.ts diff --git a/.env.example b/.env.example index 80898073..7c8ec57b 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ POSTGRES_USER="postgres" POSTGRES_PASSWORD="strong-password" POSTGRES_DB="splitpro" POSTGRES_PORT=5432 -TZ="UTC" # Set to your timezone, e.g. Europe/Warsaw. Required for scheduled jobs to execute on time. DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_CONTAINER_NAME}:${POSTGRES_PORT}/${POSTGRES_DB}" # Next Auth diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index 7f3fc8e3..19ba3326 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -16,7 +16,7 @@ services: postgres -c shared_preload_libraries=pg_cron -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} + -c cron.timezone=UTC ports: - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index 77abf810..5323f3d6 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -19,7 +19,7 @@ services: postgres -c shared_preload_libraries=pg_cron -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} + -c cron.timezone=UTC # ports: # - "5432:5432" env_file: .env diff --git a/package.json b/package.json index f024803e..3f67c883 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", + "cron-parser": "^4.9.0", "cronstrue": "^3.3.0", "date-fns": "^3.3.1", "i18next": "^25.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b50823af..d0c1d814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.515.0 version: 3.817.0 + '@date-fns/tz': + specifier: ^1.4.1 + version: 1.4.1 '@ducanh2912/next-pwa': specifier: ^10.2.9 version: 10.2.9(@types/babel__core@7.20.5)(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(webpack@5.99.9) @@ -66,12 +69,15 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 cronstrue: specifier: ^3.3.0 version: 3.3.0 date-fns: - specifier: ^3.3.1 - version: 3.6.0 + specifier: ^4.1.0 + version: 4.1.0 i18next: specifier: ^25.2.1 version: 25.2.1(typescript@5.7.2) @@ -1049,6 +1055,9 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@ducanh2912/next-pwa@10.2.9': resolution: {integrity: sha512-Wtu823+0Ga1owqSu1I4HqKgeRYarduCCKwsh1EJmJiJqgbt+gvVf5cFwFH8NigxYyyEvriAro4hzm0pMSrXdRQ==} peerDependencies: @@ -3514,6 +3523,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cronstrue@3.3.0: resolution: {integrity: sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg==} hasBin: true @@ -3555,9 +3568,6 @@ packages: date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} - date-fns@3.6.0: - resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -4578,6 +4588,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -7261,6 +7275,8 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@date-fns/tz@1.4.1': {} + '@ducanh2912/next-pwa@10.2.9(@types/babel__core@7.20.5)(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(webpack@5.99.9)': dependencies: fast-glob: 3.3.2 @@ -9857,6 +9873,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cronstrue@3.3.0: {} cross-fetch@4.0.0: @@ -9905,8 +9925,6 @@ snapshots: date-fns-jalali@4.1.0-0: {} - date-fns@3.6.0: {} - date-fns@4.1.0: {} debug@4.4.1: @@ -11211,6 +11229,8 @@ snapshots: dependencies: react: 19.1.1 + luxon@3.7.2: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 1ccadbf1..cfc08a30 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -11,6 +11,7 @@ import { currencyConversion, toSafeBigInt, toUIString } from '~/utils/numbers'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cronToBackend } from '~/lib/cron'; import { cn } from '~/lib/utils'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { Button } from '../ui/button'; @@ -122,7 +123,7 @@ export const AddOrEditExpensePage: React.FC<{ expenseDate, expenseId, transactionId, - cronExpression, + cronExpression: cronExpression ? cronToBackend(cronExpression) : undefined, }, { onSuccess: (d) => { diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 964f07bd..540b465b 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -18,6 +18,7 @@ import { Button } from '../ui/button'; import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; +import { cronFromBackend } from '~/lib/cron'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -28,7 +29,7 @@ interface ExpenseDetailsProps { } const ExpenseDetails: React.FC = ({ user, expense, storagePublicUrl }) => { - const { displayName, toUIDate, t, i18n } = useTranslationWithUtils(); + const { displayName, toUIDate, t } = useTranslationWithUtils(); const { cronParser, i18nReady } = useIntlCronParser(); @@ -75,10 +76,7 @@ const ExpenseDetails: React.FC = ({ user, expense, storageP {i18nReady ? `: - ${cronParser.toString(expense.recurrence.job.schedule, { - use24HourTimeFormat: true, - locale: i18n.language.split('-')[0], - })}` + ${cronParser(cronFromBackend(expense.recurrence.job.schedule))}` : ''}

) : null} diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 8c6bf976..6a5edb8e 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -14,12 +14,9 @@ export interface CronTextResult { value?: string; } -export function getCronText(cronParser: any, cronString: string, locale?: string): CronTextResult { +export function getCronText(cronParser: any, cronString: string): CronTextResult { try { - const value = cronParser.toString(cronString.trim(), { - use24HourTimeFormat: true, - locale, - }); + const value = cronParser(cronString.trim()); return { status: true, value }; } catch (error) { return { status: false }; @@ -512,11 +509,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) {
{(() => { - const cronString = getCronText( - cronParser, - cronExpression, - i18n.language.split('-')[0], - ); + const cronString = getCronText(cronParser, cronExpression); if (cronString.status) return (

diff --git a/src/hooks/useIntlCronParser.ts b/src/hooks/useIntlCronParser.ts index e10c4267..83eca44e 100644 --- a/src/hooks/useIntlCronParser.ts +++ b/src/hooks/useIntlCronParser.ts @@ -1,12 +1,24 @@ import React from 'react'; import cronstrue from 'cronstrue'; +import { useTranslation } from 'next-i18next'; export const useIntlCronParser = () => { - const [cronParser, setCronParser] = React.useState(null); + const { i18n } = useTranslation(); + + const [_cronParser, setCronParser] = React.useState(null); React.useEffect(() => { void import('cronstrue/i18n').then((mod) => setCronParser(mod)); }, []); - return { cronParser: cronParser || cronstrue, i18nReady: !!cronParser }; + const cronParser = React.useCallback( + (expression: string) => + (_cronParser ?? cronstrue).toString(expression, { + locale: i18n.language.split('-')[0], + use24HourTimeFormat: true, + }), + [i18n.language, _cronParser], + ); + + return { cronParser, i18nReady: !!_cronParser }; }; diff --git a/src/lib/cron.ts b/src/lib/cron.ts new file mode 100644 index 00000000..b6cfbe97 --- /dev/null +++ b/src/lib/cron.ts @@ -0,0 +1,141 @@ +import cronParser from 'cron-parser'; + +export const cronToBackend = (expression: string) => + convertCronTz(expression, new Date().getTimezoneOffset()).replaceAll('L', '$'); + +export const cronFromBackend = (expression: string) => + convertCronTz(expression, -new Date().getTimezoneOffset()).replaceAll('$', 'L'); + +const convertCronTz = (cronExpression: string, timeZoneOffset: number): string => { + const interval = cronParser.parseExpression(cronExpression); + + if (timeZoneOffset === 0) { + return cronExpression; + } + + const c = getDaysHoursMinutes( + interval.fields.hour[0]!, + interval.fields.minute[0]!, + timeZoneOffset, + ); + const cronExpressionFields = getFieldsCron(cronExpression); + + // Minute + cronExpressionFields.minute = addMinutes(cronExpressionFields.minute, c.minutes); + + // Hour + cronExpressionFields.hour = addHours(cronExpressionFields.hour, c.hours); + + // Month + if ( + (cronExpressionFields.dayOfMonth.indexOf(1) >= 0 && c.days === -1) || + (cronExpressionFields.dayOfMonth.indexOf(31) >= 0 && c.days === 1) + ) { + cronExpressionFields.month = addMonth(cronExpressionFields.month, c.days); + } + + // Day of month + cronExpressionFields.dayOfMonth = addDayOfMonth(cronExpressionFields.dayOfMonth, c.days); + + // Day of week + cronExpressionFields.dayOfWeek = addDayOfWeek(cronExpressionFields.dayOfWeek, c.days); + try { + return setFieldsCron(cronExpressionFields); + } catch (err: any) { + if (err.message.includes('Invalid explicit day of month definition')) { + cronExpressionFields.dayOfMonth = [1]; + cronExpressionFields.month = addMonth(cronExpressionFields.month, 1); + return setFieldsCron(cronExpressionFields); + } + return cronExpression; + } +}; + +const getDaysHoursMinutes = (hour: number, minute: number, timeZoneOffset: number) => { + const minutes = hour * 60 + minute; + const newMinutes = minutes + timeZoneOffset; + const diffHour = (Math.floor(newMinutes / 60) % 24) - hour; + const diffMinutes = (newMinutes % 60) - minute; + const diffDays = Math.floor(newMinutes / (60 * 24)); + + return { hours: diffHour, minutes: diffMinutes, days: diffDays }; +}; + +const getFieldsCron = (expression: string): any => { + const interval = cronParser.parseExpression(expression); + return JSON.parse(JSON.stringify(interval.fields)); +}; + +const setFieldsCron = (fields: any): string => cronParser.fieldsToExpression(fields).stringify(); + +const addHours = (hours: number[], hour: number) => + hours.map((n) => { + const h = n + hour; + if (h > 23) { + return h - 24; + } + if (h < 0) { + return h + 24; + } + return h; + }); + +const addMinutes = (minutes: number[], minute: number) => + minutes.map((n) => { + const m = n + minute; + if (m > 59) { + return m - 60; + } + if (m < 0) { + return m + 60; + } + return m; + }); + +const addDayOfMonth = (dayOfMonth: any[], day: number) => { + if (dayOfMonth.length > 30) { + return dayOfMonth; + } + return dayOfMonth.map((n) => { + const d = n + day; + if (d > 31 || n === 'L') { + return 1; + } + if (d < 1) { + return 'L'; + } + return d; + }); +}; + +const addDayOfWeek = (dayOfWeek: any[], day: number) => { + if (dayOfWeek.length > 6) { + return dayOfWeek; + } + return dayOfWeek.map((n) => { + const d = n + day; + if (d > 6) { + return 0; + } + if (d < 0) { + return 6; + } + return d; + }); +}; + +const addMonth = (month: any[], mon: number) => { + if (month.length > 11) { + return month; + } + return month.map((n) => { + const m = n + mon; + if (m > 12) { + return 1; + } + if (m < 1) { + return 12; + } + return m; + }); +}; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index 7a2da1a8..97e496f6 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -1,18 +1,19 @@ +import { type GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; -import { useTranslation } from 'next-i18next'; import { AddOrEditExpensePage } from '~/components/AddExpense/AddExpensePage'; import MainLayout from '~/components/Layout/MainLayout'; import { env } from '~/env'; +import { cronFromBackend } from '~/lib/cron'; import { parseCurrencyCode } from '~/lib/currency'; +import { isBankConnectionConfigured } from '~/server/bankTransactionHelper'; import { isStorageConfigured } from '~/server/storage'; import { useAddExpenseStore } from '~/store/addStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; -import { type GetServerSideProps } from 'next'; -import { isBankConnectionConfigured } from '~/server/bankTransactionHelper'; const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; @@ -117,7 +118,7 @@ const AddPage: NextPageWithUser<{ useAddExpenseStore.setState({ showFriends: false }); setExpenseDate(expenseQuery.data.expenseDate); if (expenseQuery.data.recurrence) { - setCronExpression(expenseQuery.data.recurrence.job.schedule); + setCronExpression(cronFromBackend(expenseQuery.data.recurrence.job.schedule)); } }, [ _expenseId, diff --git a/src/pages/recurring.tsx b/src/pages/recurring.tsx index 4b6c8069..fef71c98 100644 --- a/src/pages/recurring.tsx +++ b/src/pages/recurring.tsx @@ -6,13 +6,14 @@ import { EntityAvatar } from '~/components/ui/avatar'; import { Button } from '~/components/ui/button'; import { useIntlCronParser } from '~/hooks/useIntlCronParser'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cronFromBackend } from '~/lib/cron'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { withI18nStaticProps } from '~/utils/i18n/server'; import { toUIString } from '~/utils/numbers'; const RecurringPage: NextPageWithUser = () => { - const { t, toUIDate, i18n } = useTranslationWithUtils(); + const { t, toUIDate } = useTranslationWithUtils(); const recurringExpensesQuery = api.expense.getRecurringExpenses.useQuery(); @@ -59,10 +60,7 @@ const RecurringPage: NextPageWithUser = () => { {i18nReady ? `: - ${cronParser.toString(e.job.schedule, { - use24HourTimeFormat: true, - locale: i18n.language.split('-')[0], - })}` + ${cronParser(cronFromBackend(e.job.schedule))}` : ''}

{toUIDate(e.expense.expenseDate)}

diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index 2eb172eb..e57090b3 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -25,5 +25,5 @@ export const createRecurringExpenseJob = async (expenseId: string, cronExpressio db.$queryRawUnsafe<[{ schedule: bigint }]>( `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, expenseId, - cronExpression.replaceAll('L', '$'), + cronExpression, ); From 729548103804609fc5f444fffe49cdf93357e035 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 21:25:57 +0200 Subject: [PATCH 40/43] Editing and deleting support --- pnpm-lock.yaml | 17 +++++------ src/server/api/routers/expense.ts | 14 +++++++-- src/server/api/services/scheduleService.ts | 2 +- src/server/api/services/splitService.ts | 34 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0c1d814..db8b500e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,6 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.515.0 version: 3.817.0 - '@date-fns/tz': - specifier: ^1.4.1 - version: 1.4.1 '@ducanh2912/next-pwa': specifier: ^10.2.9 version: 10.2.9(@types/babel__core@7.20.5)(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(webpack@5.99.9) @@ -76,8 +73,8 @@ importers: specifier: ^3.3.0 version: 3.3.0 date-fns: - specifier: ^4.1.0 - version: 4.1.0 + specifier: ^3.3.1 + version: 3.6.0 i18next: specifier: ^25.2.1 version: 25.2.1(typescript@5.7.2) @@ -1055,9 +1052,6 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} - '@date-fns/tz@1.4.1': - resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@ducanh2912/next-pwa@10.2.9': resolution: {integrity: sha512-Wtu823+0Ga1owqSu1I4HqKgeRYarduCCKwsh1EJmJiJqgbt+gvVf5cFwFH8NigxYyyEvriAro4hzm0pMSrXdRQ==} peerDependencies: @@ -3568,6 +3562,9 @@ packages: date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -7275,8 +7272,6 @@ snapshots: '@date-fns/tz@1.2.0': {} - '@date-fns/tz@1.4.1': {} - '@ducanh2912/next-pwa@10.2.9(@types/babel__core@7.20.5)(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(webpack@5.99.9)': dependencies: fast-glob: 3.3.2 @@ -9925,6 +9920,8 @@ snapshots: date-fns-jalali@4.1.0-0: {} + date-fns@3.6.0: {} + date-fns@4.1.0: {} debug@4.4.1: diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 9a21bf52..d11b1ad4 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -110,13 +110,21 @@ export const expenseRouter = createTRPCRouter({ if (expense && input.cronExpression) { const [{ schedule }] = await createRecurringExpenseJob(expense.id, input.cronExpression); console.log('Created recurring expense job with jobid:', schedule); + await db.expense.update({ where: { id: expense.id }, data: { recurrence: { - create: { - job: { - connect: { jobid: schedule }, + upsert: { + create: { + job: { + connect: { jobid: schedule }, + }, + }, + update: { + job: { + connect: { jobid: schedule }, + }, }, }, }, diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index e57090b3..c91a8111 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -21,7 +21,7 @@ export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'm } }; -export const createRecurringExpenseJob = async (expenseId: string, cronExpression: string) => +export const createRecurringExpenseJob = (expenseId: string, cronExpression: string) => db.$queryRawUnsafe<[{ schedule: bigint }]>( `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, expenseId, diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 1cb8a81e..3268c3ba 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -208,6 +208,11 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { }, include: { expenseParticipants: true, + recurrence: { + include: { + job: true, + }, + }, }, }); @@ -335,6 +340,27 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { }), ); + if (expense.recurrence?.job) { + // Only delete the cron job if there's no other linked expense + const linkedExpenses = await db.expense.count({ + where: { + recurrenceId: expense.recurrenceId, + id: { + not: expense.id, + }, + }, + }); + + if (linkedExpenses === 0) { + operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); + operations.push( + db.expenseRecurrence.delete({ + where: { id: expense.recurrence.id }, + }), + ); + } + } + await db.$transaction(operations); sendExpensePushNotification(expenseId).catch(console.error); } @@ -365,6 +391,11 @@ export async function editExpense( where: { id: expenseId }, include: { expenseParticipants: true, + recurrence: { + include: { + job: true, + }, + }, }, }); @@ -589,6 +620,9 @@ export async function editExpense( } }); + if (expense.recurrence?.job) { + operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); + } await db.$transaction(operations); await updateGroupExpenseForIfBalanceIsZero( paidBy, From 28d951baff2d0951ff5984641d9e94a295452dab Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 21:28:56 +0200 Subject: [PATCH 41/43] Remove no longer used shadcn components --- src/components/ui/collapsible.tsx | 32 ------ src/components/ui/select.tsx | 170 ------------------------------ src/styles/globals.css | 24 ----- 3 files changed, 226 deletions(-) delete mode 100644 src/components/ui/collapsible.tsx delete mode 100644 src/components/ui/select.tsx diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx deleted file mode 100644 index af729804..00000000 --- a/src/components/ui/collapsible.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; -import { cn } from '~/lib/utils'; - -function Collapsible({ ...props }: React.ComponentProps) { - return ; -} - -function CollapsibleTrigger({ - ...props -}: React.ComponentProps) { - return ; -} - -function CollapsibleContent({ - className = '', - ...props -}: React.ComponentProps) { - return ( - - ); -} - -export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx deleted file mode 100644 index c442d716..00000000 --- a/src/components/ui/select.tsx +++ /dev/null @@ -1,170 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { Select as SelectPrimitive } from 'radix-ui'; -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; - -import { cn } from '~/lib/utils'; - -function Select({ ...props }: React.ComponentProps) { - return ; -} - -function SelectGroup({ ...props }: React.ComponentProps) { - return ; -} - -function SelectValue({ ...props }: React.ComponentProps) { - return ; -} - -function SelectTrigger({ - className, - size = 'default', - children, - ...props -}: React.ComponentProps & { - size?: 'sm' | 'default'; -}) { - return ( - - {children} - - - - - ); -} - -function SelectContent({ - className, - children, - position = 'popper', - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ); -} - -function SelectLabel({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} - -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -}; diff --git a/src/styles/globals.css b/src/styles/globals.css index 67413e16..a319db4b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -39,30 +39,6 @@ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; --animate-caret-blink: caret-blink 1.25s ease-out infinite; - --animate-collapsible-down: collapsible-down 0.1s ease-out; - --animate-collapsible-up: collapsible-up 0.1s ease-out; - - @keyframes collapsible-down { - from { - height: 0; - opacity: 0; - } - to { - height: var(--radix-collapsible-content-height); - opacity: 1; - } - } - - @keyframes collapsible-up { - from { - height: var(--radix-collapsible-content-height); - opacity: 1; - } - to { - height: 0; - opacity: 0; - } - } @keyframes accordion-down { from { From 9617c0b50c5555162dbef8cb82cf3b8be24df622 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 21:47:34 +0200 Subject: [PATCH 42/43] Lock the selections to single values as pg_cron does not support commas --- src/components/ui/cron-builder.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 6a5edb8e..2adc0a2e 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -281,9 +281,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const handleMonthToggle = useCallback((monthIndex: number | string) => { const monthNum = (typeof monthIndex === 'number' ? monthIndex : parseInt(monthIndex, 10)) + 1; - setMonths((prev) => - prev.includes(monthNum) ? prev.filter((m) => m !== monthNum) : [...prev, monthNum], - ); + setMonths([monthNum]); }, []); // Month button component with pressed state @@ -329,9 +327,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const handleDayOfWeekToggle = useCallback((dayIndex: number | string) => { const dayNum = typeof dayIndex === 'number' || dayIndex === 'L' ? dayIndex : parseInt(dayIndex, 10); - setDaysOfWeek((prev) => - prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], - ); + setDaysOfWeek([dayNum]); }, []); // Day of week button component with pressed state @@ -394,9 +390,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const handleDayOfMonthToggle = useCallback((day: number | string) => { const dayNum = typeof day === 'number' || day === 'L' ? day : parseInt(day, 10); - setDaysOfMonth((prev) => - prev.includes(dayNum) ? prev.filter((d) => d !== dayNum) : [...prev, dayNum], - ); + setDaysOfMonth([dayNum]); }, []); const renderDaysOfMonthGrid = () => ( From 46fe6705666f3ec050839a31f2b4eea5cd524994 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 7 Oct 2025 22:18:28 +0200 Subject: [PATCH 43/43] Remove unnecessary eslint comment --- src/components/ui/cron-builder.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index 2adc0a2e..8303f05a 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -276,7 +276,6 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { setCronExpression(''); onChange(''); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [scheduleType, minutes, hours, daysOfMonth, months, daysOfWeek, custom]); const handleMonthToggle = useCallback((monthIndex: number | string) => {