diff --git a/.env.example b/.env.example index 31ff7056d..fc533c7dc 100644 --- a/.env.example +++ b/.env.example @@ -50,11 +50,17 @@ DISABLE_EMAIL_SIGNUP=false # Uses each browser's time zone. Disabled by default to preserve existing behavior. EXCLUDE_FUTURE_EXPENSES=false -# Clean up cron job. +# Expense recurrences use this 24-hour HH:mm time (00:00-23:59) in PostgreSQL's cron.timezone. +# Changing RECURRENCE_TIME and recreating the app updates existing expense jobs at startup. +RECURRENCE_TIME="00:00" +# Used by Docker Compose to configure cron.timezone (IANA zone); recreate PostgreSQL and app after changes. +RECURRENCE_TIME_ZONE="UTC" + +# Clean up cron job. # Deletes old cached bank data, currency rates etc. based on the interval below. -# Note that the timezone for cron jobs is set to UTC timezone. -# You can use https://crontab.guru/ if you are not familiar with cron syntax. -# Be mindful that pg_cron does not support ranges, lists or steps: https://github.com/citusdata/pg_cron +# Cleanup follows PostgreSQL's cron.timezone too (UTC by default). +# You can use https://crontab.guru/ if you are not familiar with cron syntax. +# pg_cron supports ranges, lists and steps: https://github.com/citusdata/pg_cron # EXAMPLES: # Daily at 2am: "0 2 * * *" # Weekly on Sunday at 2am: "0 2 * * 0" @@ -113,7 +119,7 @@ KEYCLOAK_ID= KEYCLOAK_SECRET= KEYCLOAK_ISSUER= -# OIDC Provider +# OIDC Provider # The (lowercase) name will be used to generate an id and possibly display an icon if it is added in https://github.com/oss-apps/split-pro/blob/main/src/pages/auth/signin.tsx#L25 # If your provider is not added, simpleicon probably has it and you may submit a PR OIDC_NAME= diff --git a/docker/README.md b/docker/README.md index be0995382..78b6a8351 100644 --- a/docker/README.md +++ b/docker/README.md @@ -180,9 +180,7 @@ See [docs/CONFIGURATION.md](../docs/CONFIGURATION.md) for the `connect_timeout`/ ## Recurring transactions (pg_cron) -Recurring expenses require PostgreSQL with `pg_cron`. The example compose file already enables it. If you use another database image, you must enable the extension yourself. - -See [docs/RECURRING_TRANSACTIONS.md](../docs/RECURRING_TRANSACTIONS.md). +See [Recurring transactions](../docs/RECURRING_TRANSACTIONS.md) for requirements, configuration and deployment instructions. ### External / managed PostgreSQL (non-superuser) @@ -193,7 +191,7 @@ SplitPro does not require superuser access at runtime. A regular database role i ```sql CREATE EXTENSION IF NOT EXISTS pg_cron; GRANT USAGE ON SCHEMA cron TO ; -GRANT SELECT, REFERENCES ON cron.job TO ; +GRANT SELECT, UPDATE, REFERENCES ON cron.job TO ; GRANT SELECT ON cron.job_run_details TO ; ``` @@ -205,11 +203,11 @@ cron.database_name = '' cron.timezone = 'UTC' ``` -Restart PostgreSQL after changing these settings. +UTC is the default; choose another IANA zone in `cron.timezone` if needed. Restart PostgreSQL after changing these settings. The migration that sets up recurring expenses uses `CREATE EXTENSION IF NOT EXISTS pg_cron`, which is a no-op when the extension is already installed — no superuser privileges are needed in that case. If the extension is missing and the app user is not a superuser, PostgreSQL will reject the `CREATE EXTENSION` call; ask your DB administrator to install it beforehand using the commands above. -Jobs created by SplitPro run as the app user and are scoped to that user via `pg_cron`'s row-level security, so the app can only manage its own jobs. +Jobs created by SplitPro run as the app user and are scoped to that user via `pg_cron`'s row-level security, so the app can only manage its own jobs. The `UPDATE` grant on `cron.job` is required for schedule migration and the row locks used by startup reconciliation. ## Receipt storage diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index 07074abe6..702a0d4a0 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=UTC + -c cron.timezone=${RECURRENCE_TIME_ZONE:-UTC} healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] interval: 10s diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index 762f896a2..0edfaf063 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=UTC + -c cron.timezone=${RECURRENCE_TIME_ZONE:-UTC} # ports: # - "5432:5432" env_file: .env @@ -35,6 +35,8 @@ services: environment: - PORT=${PORT:-3000} - HOSTNAME=0.0.0.0 + # Execution time in PostgreSQL's cron.timezone; applied to expense jobs at startup. + - RECURRENCE_TIME=${RECURRENCE_TIME:-00:00} env_file: .env healthcheck: test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:${PORT:-3000}/api/healthz'] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c785bbda2..6e6eb6de4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -34,7 +34,7 @@ If SplitPro connects to an external/managed PostgreSQL instance, make sure your ```sql CREATE EXTENSION IF NOT EXISTS pg_cron; GRANT USAGE ON SCHEMA cron TO ; - GRANT SELECT, REFERENCES ON cron.job TO ; + GRANT SELECT, UPDATE, REFERENCES ON cron.job TO ; GRANT SELECT ON cron.job_run_details TO ; ``` @@ -47,7 +47,9 @@ If SplitPro connects to an external/managed PostgreSQL instance, make sure your SELECT extname FROM pg_extension WHERE extname = 'pg_cron'; ``` -SplitPro can then run with a regular (non-superuser) `POSTGRES_USER`. +SplitPro can then run with a regular (non-superuser) `POSTGRES_USER`. The `UPDATE` +permission on `cron.job` is needed for schedule migration and the row locks used by +startup reconciliation. pg_cron's row-level security scopes jobs to their owner. If your deployment sits behind a NAT/firewall/proxy that can silently drop idle TCP connections (leaving the pool with dead sockets that hang instead of erroring), add `connect_timeout` and `socket_timeout` (seconds) to `DATABASE_URL` so Prisma gives up on a stale connection instead of hanging indefinitely - e.g. `?connect_timeout=5&socket_timeout=5`. `/api/readyz`'s own query timeout only bounds queries that reach Postgres; it can't help if the socket itself is dead. See [docker/README.md#health-checks](../docker/README.md#health-checks) for `/api/healthz`/`/api/readyz` details and example probe configuration. @@ -68,12 +70,18 @@ At least one provider must be configured. SplitPro does not support username/pas ## Optional variables +### Recurring expenses (pg_cron) + +See [Recurring transactions](RECURRING_TRANSACTIONS.md) for configuration and deployment instructions. + ### Cache cleanup (pg_cron) -- `CLEAR_CACHE_CRON_RULE`: Cron rule for cache cleanup jobs (UTC). +- `CLEAR_CACHE_CRON_RULE`: Cron rule for cache cleanup jobs in database `cron.timezone` (UTC by default). - `CACHE_RETENTION_INTERVAL`: Postgres interval string that defines how long cached data can remain unused. -`pg_cron` does not support cron ranges or lists. See https://github.com/citusdata/pg_cron#what-is-pg_cron for details. +For time-zone behavior, see [Recurring transactions](RECURRING_TRANSACTIONS.md). +Standard cron ranges, lists and steps are supported; see the +[pg_cron documentation](https://github.com/citusdata/pg_cron#cron-syntax). ### SMTP (email provider) diff --git a/docs/RECURRING_TRANSACTIONS.md b/docs/RECURRING_TRANSACTIONS.md index 9541e4b71..5ad23faf5 100644 --- a/docs/RECURRING_TRANSACTIONS.md +++ b/docs/RECURRING_TRANSACTIONS.md @@ -1,51 +1,180 @@ -# RECURRING TRANSACTIONS +# Recurring transactions -SplitPro supports recurring expenses using PostgreSQL cron jobs. This requires the `pg_cron` extension. +SplitPro uses PostgreSQL's `pg_cron` extension to generate recurring expenses. +Users select calendar days; the instance operator configures one execution time +and time zone for all expense recurrences. ## Requirements - PostgreSQL with `pg_cron` enabled. -- Use the provided Docker image (`ossapps/postgres`) or a compatible Postgres build with the extension installed. -- See [docker/prod/compose.yml](../docker/prod/compose.yml) for a working configuration example. - -## How it works +- Use the provided `ossapps/postgres` image or a compatible PostgreSQL installation. +- The [production Compose file](../docker/prod/compose.yml) enables the extension + and configures its time zone. For an external database, see + [PostgreSQL prerequisites](CONFIGURATION.md#external-postgresql-prerequisites-pg_cron). + +## Creating and managing recurrences + +Choose daily, weekly, monthly or yearly repetition in the recurrence editor. +Select weekdays, days of the month or a month as appropriate. There is no time +picker: all rules use the instance execution time. Recurrence management is +available from the Activity tab. + +A recurring expense starts with a template expense. Editing the template changes +future occurrences and leaves existing derived expenses unchanged. Deleting the +template stops the rule and preserves generated expenses. Editing or deleting a +derived expense does not change the template's schedule. + +The initial expense already covers its date. A scheduled run on or before that +date creates nothing. Subsequent runs can generate at most one expense per +recurrence per instance-calendar day, including after retries or deletion of a +generated expense. Existing expense timestamps are interpreted in the instance +time zone for this check. + +## Execution time and calendar + +| Setting | Default | Purpose | +| ---------------------- | ------- | -------------------------------------------------------------------------------------- | +| `RECURRENCE_TIME` | `00:00` | App setting: execution time in 24-hour `HH:mm` format, from `00:00` through `23:59`. | +| `RECURRENCE_TIME_ZONE` | `UTC` | Compose setting: configures PostgreSQL's `cron.timezone`, for example `Europe/Warsaw`. | + +`cron.timezone` is the source of truth for weekdays, month boundaries and execution +time. Browser and application-server OS time zones do not change saved calendar +fields. For example, a monthly last-day rule remains a last-day rule in the instance +calendar regardless of who creates or edits it. + +All recurrence rules on an instance share these settings. `RECURRENCE_TIME` is a +runtime app setting; no rebuild is required. Invalid time values fail app environment +validation. For external/managed PostgreSQL, set `cron.timezone` on the database +server: setting `RECURRENCE_TIME_ZONE` on the application alone does not configure +an external database. The app reads the actual database setting for previews. + +The configured time is when a job is scheduled to run. Actual execution can be +delayed by database load or availability. Missed days during database downtime are +not automatically backfilled. + +## What users see in their browser + +The editor, recurring-expenses list and expense details show the **next scheduled +run as a full local date and time in the browser's time zone**, with that zone's +name. Underneath, they show the configured schedule and its instance time zone. +The browser preview refreshes every minute. + +For example, with `RECURRENCE_TIME=02:35` and `RECURRENCE_TIME_ZONE=UTC`, a monthly +rule for January 1 runs at January 1, 2027, 02:35 UTC. A browser in +`America/Los_Angeles` shows December 31, 2026, 18:35. The saved rule still means +January 1 in UTC. + +The preview converts a concrete upcoming occurrence, using the daylight-saving +offsets for that date. A fixed instance time may therefore appear at different +local times in summer and winter. It does not promise the same browser-local day +to users around the world. Changing the browser zone changes the display, not the +saved rule. The preview describes a scheduled job run; the initial-expense and +already-generated-date checks can still make that run a no-op. + +## Production Docker Compose configuration + +In the directory containing the production `compose.yml`, add these values to +`.env`: + +```dotenv +RECURRENCE_TIME="06:35" +RECURRENCE_TIME_ZONE="Europe/Warsaw" +``` -- A recurring expense is created from a template expense. -- The schedule uses a cron rule and generates derived expenses. -- Editing the template updates future occurrences only. -- Deleting the template removes the recurrence rule but preserves generated expenses. -- Recurrence management is available from the Activity tab. +The PostgreSQL service receives the zone through `-c cron.timezone=...`; the app +receives `RECURRENCE_TIME` through its environment. Both have defaults, so existing +`.env` files continue to use midnight UTC. -## Editing behavior +After changing only the execution time, recreate the app container without its dependencies: -- Template edits update the schedule and future instances. -- Existing derived expenses are not modified when you edit the template. -- Deleting the template removes the recurrence rule while keeping derived expenses. +```bash +docker compose up -d --no-deps --force-recreate splitpro +``` -## Cron limitations +`--no-deps` keeps PostgreSQL running during a time-only update. PostgreSQL also loads +`.env`, so otherwise its changed environment can cause an unnecessary recreation. -`pg_cron` supports a subset of standard cron syntax. Ranges and lists are not supported. -Refer to https://github.com/citusdata/pg_cron#what-is-pg_cron for details. +After changing the time zone, recreate PostgreSQL and the app so the database uses +the new zone and the app reconciles schedules against it: -## Example schedules +```bash +docker compose up -d --force-recreate postgres splitpro +``` -- Daily at 02:00 UTC: `0 2 * * *` -- Weekly on Sunday at 02:00 UTC: `0 2 * * 0` -- Monthly on the 1st at 02:00 UTC: `0 2 1 * *` +These commands assume the production Compose file and `.env` are in the current +directory. A plain `docker compose restart` does not apply changed environment +variables or container command arguments. Recreating the containers keeps their +named data volumes; do not remove those volumes when changing recurrence settings. + +On every normal Node.js server startup, the instrumentation hook runs data +migrations, then checks existing expense jobs against `RECURRENCE_TIME`. It changes +only schedules that differ, preserving calendar fields, job IDs, commands and +active status. New and edited recurrences use the same setting. Reconciliation is +skipped in `TEST_MODE`; unchanged schedules are left alone on repeated startups. +Deploy the same execution time to every application replica sharing the database. + +`cron.timezone` applies to **all pg_cron jobs**, including cache cleanup. Changing +`RECURRENCE_TIME` only updates expense jobs; changing the zone also changes the +wall-clock interpretation of `CLEAR_CACHE_CRON_RULE`. Review maintenance schedules +when changing the zone. Existing historical expense dates are not rewritten. + +## Calendar rules and examples + +With `RECURRENCE_TIME=06:35`, the resulting database schedules are: + +| Selection | pg_cron schedule in the instance time zone | +| ----------------------- | ------------------------------------------ | +| Daily | `35 6 * * *` | +| Every Sunday | `35 6 * * 0` | +| First day of each month | `35 6 1 * *` | +| Last day of each month | `35 6 $ * *` | +| January 1 each year | `35 6 1 1 *` | + +The UI uses `L` for last day; PostgreSQL uses `$`. This marker conversion does not +shift dates or months. Last day follows the actual month length, including leap +years. A rule for the 31st skips months without a 31st, and February 29 skips +non-leap years. Legacy custom rules retain cron semantics: restricting both the +day of month and weekday matches either field. + +`pg_cron` supports standard five-field cron expressions, including ranges, lists +and steps, plus its `$` last-day marker. The recurrence editor offers calendar +choices rather than arbitrary execution times. See the +[pg_cron documentation](https://github.com/citusdata/pg_cron#cron-syntax) for syntax. +Choose an execution time away from local daylight-saving clock transitions if you +want to avoid skipped or repeated wall-clock times in that zone. + +## Upgrading existing recurrences + +The date-only migration removes saved hour/minute choices while retaining stored +calendar fields. Startup reconciliation then applies `RECURRENCE_TIME`. It also +records existing occurrence dates to prevent duplicate generation during the +upgrade. Existing expenses and participants are preserved. + +The old browser-offset conversion did not store the original zone or calendar +intent. Rules already shifted incorrectly cannot be repaired automatically. After +upgrading, review existing rules, especially the first/last day of a month and +rules near year boundaries. Correct those schedules in the editor; historical +expenses are not automatically moved or deleted. ## Checking pg_cron -You can verify the extension inside Postgres: +Run these queries in the SplitPro database: ```sql SELECT * FROM pg_extension WHERE extname = 'pg_cron'; -``` +SHOW cron.timezone; -## Configuration +SELECT j.jobid, j.schedule, j.active +FROM cron.job j +JOIN "ExpenseRecurrence" r ON r."jobId" = j.jobid; +``` -Recurring transactions rely on `pg_cron` and also use cache cleanup jobs configured via `CLEAR_CACHE_CRON_RULE` and `CACHE_RETENTION_INTERVAL`. See [CONFIGURATION](CONFIGURATION.md). +The startup log reports how many recurrence schedules were updated and the target +execution time. For other environment settings, see [Configuration](CONFIGURATION.md). ## UI walkthrough videos +These videos demonstrate the general workflow; older recordings may show the removed time picker. + - Creating a recurrence: https://github.com/user-attachments/assets/39799e04-030d-42da-8feb-7805f17ecb5e - Managing recurrences: https://github.com/user-attachments/assets/da291cdf-7db6-4fb5-8a33-806216dfca21 diff --git a/jest.integration.config.ts b/jest.integration.config.ts index 81e0f109f..56cb6d391 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -5,6 +5,8 @@ import { loadTestEnvironment } from './src/tests/helpers/environment.ts'; loadTestEnvironment(); process.env.DATABASE_URL = getTestDatabaseUrl(); process.env.SKIP_ENV_VALIDATION = '1'; +// Keep schedules deterministic because skipped env validation also skips Zod defaults. +process.env.RECURRENCE_TIME = '00:00'; const integrationJestConfig = () => configureJest({ diff --git a/prisma/migrations/20261101130000_date_only_recurrence/migration.sql b/prisma/migrations/20261101130000_date_only_recurrence/migration.sql new file mode 100644 index 000000000..f0813d07a --- /dev/null +++ b/prisma/migrations/20261101130000_date_only_recurrence/migration.sql @@ -0,0 +1,64 @@ +-- Preserve saved calendar fields; original browser time zones cannot be recovered. +ALTER TABLE "ExpenseRecurrence" ADD COLUMN "lastOccurrenceDate" DATE; +UPDATE cron.job j +SET schedule = '0 0 ' || array_to_string((regexp_split_to_array(trim(j.schedule), '\s+'))[3:5], ' ') +FROM "ExpenseRecurrence" r WHERE r."jobId" = j.jobid; + +-- Existing generated expenses count as consumed occurrences, including soft-deleted ones. +UPDATE "ExpenseRecurrence" r +SET "lastOccurrenceDate" = dates.last_day +FROM ( + SELECT "recurrenceId", max((("expenseDate" AT TIME ZONE 'UTC') AT TIME ZONE + coalesce(current_setting('cron.timezone', true), 'UTC'))::date) AS last_day + FROM "Expense" + GROUP BY "recurrenceId" +) dates +WHERE dates."recurrenceId" = r.id; + +CREATE OR REPLACE FUNCTION duplicate_expense_with_participants(original_expense_id UUID) +RETURNS UUID AS $$ +DECLARE + new_expense_id UUID; + recurrence_id INTEGER; + calendar_zone TEXT := coalesce(current_setting('cron.timezone', true), 'UTC'); + occurrence_day DATE := (CURRENT_TIMESTAMP AT TIME ZONE calendar_zone)::date; + initial_day DATE; + last_day DATE; +BEGIN + SELECT "recurrenceId" INTO recurrence_id FROM "Expense" WHERE id = original_expense_id; + IF recurrence_id IS NULL THEN RETURN NULL; END IF; + SELECT "lastOccurrenceDate" INTO last_day FROM "ExpenseRecurrence" + WHERE id = recurrence_id FOR UPDATE; + SELECT (("expenseDate" AT TIME ZONE 'UTC') AT TIME ZONE calendar_zone)::date + INTO initial_day FROM "Expense" WHERE id = original_expense_id; + IF occurrence_day <= initial_day OR occurrence_day <= last_day THEN + RETURN NULL; + END IF; + UPDATE "ExpenseRecurrence" SET "lastOccurrenceDate" = occurrence_day WHERE id = recurrence_id; + -- STEP 1: Insert the new expense and get its new ID + INSERT INTO "Expense" ( + "paidBy", "addedBy", name, category, amount, "splitType", "expenseDate", "updatedAt", + currency, "groupId", "updatedBy", "recurrenceId" + ) + SELECT + "paidBy", "addedBy", name, category, amount, "splitType", CURRENT_TIMESTAMP AT TIME ZONE 'UTC', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + currency, "groupId", "updatedBy", "recurrenceId" + FROM "Expense" + WHERE id = original_expense_id + RETURNING id INTO new_expense_id; + -- STEP 2: Insert the new expense participants + INSERT INTO "ExpenseParticipant" ( + "expenseId", "userId", amount + ) + SELECT + new_expense_id, "userId", amount + FROM "ExpenseParticipant" + WHERE "expenseId" = original_expense_id; + -- STEP 3: Set notified to false in the ExpenseRecurrence table + UPDATE "ExpenseRecurrence" + SET notified = false + WHERE id = (SELECT "recurrenceId" FROM "Expense" WHERE id = original_expense_id); + -- STEP 4: Return the new expense ID + RETURN new_expense_id; +END; +$$ LANGUAGE plpgsql; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 049b9697e..93617ad53 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -226,11 +226,12 @@ model ExpenseNote { } model ExpenseRecurrence { - id Int @id @default(autoincrement()) - jobId BigInt @unique - notified Boolean @default(true) - expense Expense[] - job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) + lastOccurrenceDate DateTime? @db.Date + 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/public/locales/en/common.json b/public/locales/en/common.json index 341ff3942..8b23f9faa 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -433,7 +433,11 @@ "template_delete_warning": "This is a recurring expense template. Deleting it will stop all future recurring expenses.", "derived_delete_warning": "This expense is part of a recurrence. Only this expense will be deleted. Future recurring expenses will continue.", "view_recurring_page": "View Recurring page", - "toggle_options": "Toggle recurring expense options" + "toggle_options": "Toggle recurring expense options", + "execution_time_loading": "Loading execution time…", + "execution_time_unavailable": "Execution time is unavailable.", + "next_local_run": "Next scheduled run: {date} ({timeZone}, your time zone).", + "instance_schedule": "Schedule: {schedule} ({timeZone})." }, "ui": { "added_by": "Added by", diff --git a/src/components/AddExpense/RecurrenceInput.tsx b/src/components/AddExpense/RecurrenceInput.tsx index 58b8d81a8..3f61cf90b 100644 --- a/src/components/AddExpense/RecurrenceInput.tsx +++ b/src/components/AddExpense/RecurrenceInput.tsx @@ -33,6 +33,7 @@ export const RecurrenceInput: React.FC = ({ shouldCloseOnAction actionTitle={t('actions.confirm')} className="h-[70vh]" + dialogClassName="h-[70vh] grid-rows-[auto_minmax(0,1fr)_auto]" > diff --git a/src/components/Expense/EditRecurrenceDialog.tsx b/src/components/Expense/EditRecurrenceDialog.tsx index 3dfdd81cd..f5c2b57cb 100644 --- a/src/components/Expense/EditRecurrenceDialog.tsx +++ b/src/components/Expense/EditRecurrenceDialog.tsx @@ -78,6 +78,7 @@ export const EditRecurrenceDialog: React.FC = ({ actionOnClick={handleDrawerConfirm} actionDisabled={!cronValue} className="h-[70vh]" + dialogClassName="h-[70vh] grid-rows-[auto_minmax(0,1fr)_auto]" > diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 87e2996e9..d21255f16 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -7,12 +7,10 @@ import { Trans } from 'next-i18next'; import type { inferRouterOutputs } from '@trpc/server'; import { ArrowRightIcon, Landmark, Merge, PencilIcon, Users } from 'lucide-react'; import Link from 'next/link'; -import React, { type ComponentProps, useCallback, useMemo, useState } from 'react'; +import React, { type ComponentProps, useCallback, useState } from 'react'; import { toast } from 'sonner'; -import { useIntlCronParser } from '~/hooks/useIntlCronParser'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; -import { cronFromBackend } from '~/lib/cron'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { isCurrencyCode } from '~/lib/currency'; import type { ExpenseRouter } from '~/server/api/routers/expense'; @@ -28,6 +26,7 @@ import { CurrencyInput } from '../ui/currency-input'; import { AppDrawer } from '../ui/drawer'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; +import { RecurrencePreview } from './RecurrencePreview'; import { DateSelector } from '../AddExpense/DateSelector'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -40,23 +39,6 @@ interface ExpenseDetailsProps { const ExpenseDetails: React.FC = ({ user, expense }) => { const { displayName, toUIDate, t, getCurrencyHelpersCached } = useTranslationWithUtils(); - const { cronParser, i18nReady } = useIntlCronParser(); - - const cronString = useMemo(() => { - if (!expense.recurrence) { - return null; - } - try { - return cronParser(cronFromBackend(expense.recurrence.job.schedule)); - } catch { - toast.error(t('errors.invalid_cron_expression')); - console.error( - `Failed to parse cron expression for expense: ${expense.recurrence.job.schedule}`, - ); - return null; - } - }, [t, expense.recurrence, cronParser]); - const { toUIString } = getCurrencyHelpersCached(expense.currency); return ( @@ -105,8 +87,7 @@ const ExpenseDetails: React.FC = ({ user, expense }) => { )} {expense.recurrence ? ( - {t('recurrence.recurring')} - {i18nReady ? `: ${cronString}` : ''} + ) : null} {expense.group ? ( diff --git a/src/components/Expense/RecurrencePreview.tsx b/src/components/Expense/RecurrencePreview.tsx new file mode 100644 index 000000000..b8e2666dc --- /dev/null +++ b/src/components/Expense/RecurrencePreview.tsx @@ -0,0 +1,52 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useTranslation } from 'next-i18next'; + +import { useIntlCronParser } from '~/hooks/useIntlCronParser'; +import { useIsClient } from '~/hooks/useIsClient'; +import { api } from '~/utils/api'; + +interface RecurrencePreviewProps { + schedule: string; +} + +/** Render browser-local times only after hydration; SSR's zone belongs to the server. */ +export const RecurrencePreview = ({ schedule }: RecurrencePreviewProps) => { + const { t, i18n } = useTranslation(); + const { cronParser } = useIntlCronParser(); + const isClient = useIsClient(); + const preview = api.expense.getRecurrencePreview.useQuery( + { cronExpression: schedule }, + { refetchInterval: 60_000, placeholderData: keepPreviousData }, + ); + + if (preview.isError) { + return {t('recurrence.execution_time_unavailable')}; + } + if (!preview.data || !isClient) { + return {t('recurrence.execution_time_loading')}; + } + + const browserTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + const { nextRun, timeZone, schedule: serverSchedule } = preview.data; + const localDate = new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'full', + timeStyle: 'short', + timeZone: browserTimeZone, + }).format(nextRun); + + return ( + + {t('recurrence.next_local_run', { date: localDate, timeZone: browserTimeZone })} + + {t('recurrence.instance_schedule', { + schedule: cronParser(serverSchedule.replaceAll('$', 'L')), + timeZone, + })} + + + ); +}; diff --git a/src/components/ui/cron-builder.tsx b/src/components/ui/cron-builder.tsx index c1a0c9cc9..d7f666e36 100644 --- a/src/components/ui/cron-builder.tsx +++ b/src/components/ui/cron-builder.tsx @@ -1,13 +1,12 @@ // copied from: https://github.com/vpfaiz/cron-builder-ui/ -import { format } from 'date-fns'; import { type TFunction, useTranslation } from 'next-i18next'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useId, useState } from 'react'; import { useIntlCronParser } from '~/hooks/useIntlCronParser'; -import { Input } from './input'; +import { RecurrencePreview } from '../Expense/RecurrencePreview'; + import { Label } from './label'; -import { NativeSelect, NativeSelectOption } from './native-select'; import { ToggleGroup, ToggleGroupItem } from './toggle-group'; export interface CronTextResult { @@ -35,7 +34,7 @@ interface ParsedCron { values: { minutes?: number[]; hours?: number[]; - daysOfMonth?: number[]; + daysOfMonth?: Array; months?: number[]; daysOfWeek?: number[]; custom?: string; @@ -49,12 +48,13 @@ const SCHEDULE_TYPES = (t: TFunction) => { 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 = ReturnType[number]; const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1); +// Yearly February 29 rules remain available for leap years. +const MONTH_LENGTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] as const; const MONTHS_SHORT = (code: string) => Array.from({ length: 12 }, (_, i) => { @@ -74,18 +74,11 @@ interface ScheduleFieldsProps { renderDaysOfWeekSelect: () => React.ReactNode; renderMonthsSelect: () => React.ReactNode; renderDaysOfMonthSelect: () => React.ReactNode; - renderTimeInput: () => React.ReactNode; } const ScheduleFields = React.memo( - ({ - scheduleType, - renderDaysOfWeekSelect, - renderMonthsSelect, - renderDaysOfMonthSelect, - renderTimeInput, - }) => { - const outputs = [renderTimeInput]; + ({ scheduleType, renderDaysOfWeekSelect, renderMonthsSelect, renderDaysOfMonthSelect }) => { + const outputs: Array<() => React.ReactNode> = []; if ('never' === scheduleType) { return null; @@ -110,6 +103,7 @@ ScheduleFields.displayName = 'ScheduleFields'; export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const { t, i18n } = useTranslation(); + const fieldId = useId(); const defaultSchedule = value; // Use provided default or fallback @@ -160,7 +154,15 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { values: { minutes: parseNumbers(min), hours: parseNumbers(hour), - daysOfMonth: parseNumbers(dom), + daysOfMonth: 'L' === dom ? ['L'] : parseNumbers(dom), + }, + }; + } else if (dom !== '*' && month !== '*' && dow === '*') { + return { + type: 'year', + values: { + daysOfMonth: 'L' === dom ? ['L'] : parseNumbers(dom), + months: parseNumbers(month), }, }; } else { @@ -175,8 +177,6 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { 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], ); @@ -184,14 +184,14 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { const [daysOfWeek, setDaysOfWeek] = useState>( initialParsed.values.daysOfWeek || [0], ); - const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); + const [custom, setCustom] = useState( + (initialParsed.values.custom || defaultSchedule).trim().split(/\s+/).slice(-3).join(' '), + ); const [cronExpression, setCronExpression] = useState(defaultSchedule); const { cronParser } = useIntlCronParser(); function loadDefaults() { - setMinutes([0]); - setHours([0]); setDaysOfMonth([1]); setMonths([1]); setDaysOfWeek([0]); @@ -204,14 +204,12 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { 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(','); + const hoursCSV = '0'; + const minutesCSV = '0'; switch (scheduleType) { case 'day': @@ -226,9 +224,11 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { case 'year': expression = `${minutesCSV} ${hoursCSV} ${domCSV} ${monthsCSV} *`; break; - case 'custom': - expression = custom || ''; + case 'custom': { + const fields = custom.trim().split(/\s+/); + expression = 3 === fields.length ? `0 0 ${fields.join(' ')}` : ''; break; + } default: expression = ''; } @@ -240,103 +240,89 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { setCronExpression(''); onChange(''); } - }, [scheduleType, minutes, hours, daysOfMonth, months, daysOfWeek, custom]); - - const handleMonthToggle = useCallback((monthIndex: number | string) => { - const monthNum = (typeof monthIndex === 'number' ? monthIndex : parseInt(monthIndex, 10)) + 1; - setMonths([monthNum]); - }, []); + }, [scheduleType, daysOfMonth, months, daysOfWeek, custom]); const renderMonthsSelect = () => (
- - + {t('recurrence.months')} + + handleMonthToggle(parseInt(e.target.value, 10) - 1)} + onValueChange={(value) => { + if (!value) return; + const month = Number(value); + const lastDay = MONTH_LENGTHS[month - 1] ?? 31; + setMonths([month]); + setDaysOfMonth((days) => days.map((day) => ('L' === day ? day : Math.min(day, lastDay)))); + }} + aria-labelledby={`${fieldId}-months`} + className="grid grid-cols-4" > {MONTHS_SHORT(i18n.language).map((month, index) => ( - + {month} - + ))} - +
); - const handleDayOfWeekToggle = useCallback((dayIndex: number | string) => { - const dayNum = - typeof dayIndex === 'number' || dayIndex === 'L' ? dayIndex : parseInt(dayIndex, 10); - setDaysOfWeek([dayNum]); - }, []); - const renderDaysOfWeekSelect = () => (
- - + {t('recurrence.days_of_week')} + + handleDayOfWeekToggle(parseInt(e.target.value, 10))} + onValueChange={(value) => { + if (value) setDaysOfWeek([Number(value)]); + }} + aria-labelledby={`${fieldId}-weekdays`} + className="grid grid-cols-7" > {DAYS_SHORT(i18n.language).map((day, index) => ( - + {day} - + ))} - +
); - const handleDayOfMonthToggle = useCallback((day: number | string) => { - const dayNum = typeof day === 'number' || day === 'L' ? day : parseInt(day, 10); - setDaysOfMonth([dayNum]); - }, []); + const availableDays = 'year' === scheduleType ? (MONTH_LENGTHS[(months[0] ?? 1) - 1] ?? 31) : 31; const renderDaysOfMonthSelect = () => (
- - + {t('recurrence.days_of_month')} + + - handleDayOfMonthToggle(e.target.value === 'L' ? 'L' : parseInt(e.target.value, 10)) - } + onValueChange={(value) => { + if (value) setDaysOfMonth(['L' === value ? 'L' : Number(value)]); + }} + aria-labelledby={`${fieldId}-monthdays`} + className="grid grid-cols-7" > - {DAYS_OF_MONTH.map((day) => ( - + {DAYS_OF_MONTH.slice(0, availableDays).map((day) => ( + {day.toString().padStart(2, '0')} - + ))} - {t('recurrence.last_day')} - + + {t('recurrence.last_day')} + +
); - const renderTimeInput = () => { - const [value, setValue] = useState( - format(new Date().setHours(hours[0] || 0, minutes[0] || 0), 'HH:mm'), - ); - - return ( -
- - { - const date = e.target.valueAsDate; - setValue(e.target.value); - if (date) { - setHours([date.getUTCHours()]); - setMinutes([date.getUTCMinutes()]); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
- ); - }; - // Render Cron expression builder UI return (
@@ -382,7 +368,7 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { 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" + placeholder="* * 0" />
) : ( @@ -391,21 +377,17 @@ export function CronBuilder({ onChange, value, className }: CronBuilderProps) { renderDaysOfWeekSelect={renderDaysOfWeekSelect} renderMonthsSelect={renderMonthsSelect} renderDaysOfMonthSelect={renderDaysOfMonthSelect} - renderTimeInput={renderTimeInput} /> )} -
+
{(() => { const cronString = getCronText(cronParser, cronExpression); if (cronString.status) return ( -

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

+
+ +
); else return ( diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 13e089ac3..586fc0131 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -156,6 +156,7 @@ interface AppDrawerProps { disableTrigger?: boolean; onTriggerClick?: React.MouseEventHandler; className?: string; + dialogClassName?: string; open?: boolean; onOpenChange?: (open: boolean) => void; title?: string; @@ -185,6 +186,7 @@ export const AppDrawer: React.FC = (props) => { leftActionOnClick, shouldCloseOnLeftAction, className, + dialogClassName, dismissible, actionDisabled, disableTrigger, @@ -220,6 +222,7 @@ export const AppDrawer: React.FC = (props) => { {trigger} { if (false === dismissible) { diff --git a/src/env.ts b/src/env.ts index 4cc37131b..53bf0a6ba 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,6 +3,11 @@ import { z } from 'zod'; import { parseEnvBoolean } from './utils/env'; +const recurrenceTimeSchema = z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'RECURRENCE_TIME must use HH:mm (00:00–23:59)') + .default('00:00'); + export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -36,6 +41,7 @@ export const env = createEnv({ (str) => process.env.VERCEL_URL ?? str, process.env.VERCEL ? z.string() : z.string().url(), ), + RECURRENCE_TIME: recurrenceTimeSchema, CLEAR_CACHE_CRON_RULE: z.string().optional(), CACHE_RETENTION_INTERVAL: z.string().optional(), ENABLE_SENDING_INVITES: z.boolean(), @@ -108,6 +114,8 @@ export const env = createEnv({ NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_URL_INTERNAL: process.env.NEXTAUTH_URL_INTERNAL ?? process.env.NEXTAUTH_URL, + // Skipping validation also skips Zod defaults; resolve the same schema default here. + RECURRENCE_TIME: process.env.RECURRENCE_TIME || recurrenceTimeSchema.parse(undefined), CLEAR_CACHE_CRON_RULE: process.env.CLEAR_CACHE_CRON_RULE ?? '0 2 * * 0', CACHE_RETENTION_INTERVAL: process.env.CACHE_RETENTION_INTERVAL ?? '2 days', ENABLE_SENDING_INVITES: parseEnvBoolean(process.env.ENABLE_SENDING_INVITES), diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 490cfb972..0cab769f2 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -13,6 +13,13 @@ export async function register() { const { runMigrations } = await import('./migrations'); await runMigrations(); + if (!env.TEST_MODE) { + const { reconcileRecurrenceSchedules } = + await import('./server/api/services/recurrenceScheduleService'); + const updatedSchedules = await reconcileRecurrenceSchedules(); + console.info(`Updated ${updatedSchedules} recurrence schedules to ${env.RECURRENCE_TIME}`); + } + const { validateAuthEnv } = await import('./server/auth'); validateAuthEnv(); diff --git a/src/lib/cron.test.ts b/src/lib/cron.test.ts index 0caf2a85b..919b6a0b0 100644 --- a/src/lib/cron.test.ts +++ b/src/lib/cron.test.ts @@ -1,65 +1,19 @@ import { cronFromBackend, cronToBackend } from '~/lib/cron'; -const mockTimezoneOffset = (offset: number) => - jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(offset); - -describe('cron timezone conversions', () => { - afterEach(() => { +describe('date-only recurrence', () => { + it.each([-840, -120, 0, 90, 720])('preserves dates at offset %s', (offset) => { + jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(offset); + expect(cronToBackend('0 0 L * *')).toBe('0 0 $ * *'); + expect(cronToBackend('0 0 1 3 *')).toBe('0 0 1 3 *'); + expect(cronToBackend('0 0 * * 0')).toBe('0 0 * * 0'); jest.restoreAllMocks(); }); - - it('shifts hours and minutes for negative timezone offsets', () => { - mockTimezoneOffset(-120); - - expect(cronToBackend('0 9 * * *')).toBe('0 7 * * *'); - }); - - it('shifts hours and minutes for positive offsets', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 9 * * *')).toBe('0 11 * * *'); - }); - - it('handles minute underflow and day-of-week shift', () => { - mockTimezoneOffset(-90); - - expect(cronToBackend('15 0 * * 0')).toBe('45 22 * * 6'); - }); - - it('rolls month forward when crossing from the 31st', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 23 31 1 *')).toBe('0 1 1 2 *'); - }); - - it('rolls month backward and converts to last-day marker', () => { - mockTimezoneOffset(-60); - - expect(cronToBackend('0 0 1 3 *')).toBe('0 23 $ 2 *'); - }); - - it('recovers from invalid day-of-month after timezone shift', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 23 30 4 *')).toBe('0 1 1 5 *'); - }); - - it('restores last-day marker from backend notation', () => { - mockTimezoneOffset(0); - - expect(cronFromBackend('0 0 $ * *')).toBe('0 0 L * *'); + it('drops time without shifting dates', () => { + expect(cronToBackend('15 23 31 12 *')).toBe('0 0 31 12 *'); + expect(cronFromBackend('30 22 $ * *')).toBe('0 0 L * *'); }); - - it('restores last-day marker from frontend notation', () => { - mockTimezoneOffset(0); - - expect(cronToBackend('0 0 L * *')).toBe('0 0 $ * *'); - }); - - it('round-trips frontend to backend and back', () => { - mockTimezoneOffset(60); - const expression = '15 5 * * 1'; - - expect(cronFromBackend(cronToBackend(expression))).toBe(expression); + it('rejects malformed schedules', () => { + expect(() => cronToBackend('10 seconds')).toThrow(); + expect(() => cronToBackend('0 0 32 * *')).toThrow(); }); }); diff --git a/src/lib/cron.ts b/src/lib/cron.ts index cb7dbf205..5db1fb5b3 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -1,144 +1,20 @@ import cronParser from 'cron-parser'; -export const cronToBackend = (expression: string) => - convertCronTz(expression, new Date().getTimezoneOffset()).replaceAll('L', '$'); - -export const cronFromBackend = (expression: string) => - convertCronTz(expression.replaceAll('$', 'L'), -new Date().getTimezoneOffset()); - -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; +/** Calendar fields belong to the instance calendar, never to the browser offset. */ +export const normalizeRecurrence = (expression: string): string => { + const fields = expression.trim().replaceAll('$', 'L').split(/\s+/); + if (5 !== fields.length) { + throw new Error('Expected a five-field recurrence'); } - return dayOfMonth.map((n) => { - const d = n + day; - if (d > 31 || n === 'L') { - return 1; - } - if (d < 1) { - return 'L'; - } - return d; - }); + const normalized = `0 0 ${fields.slice(2).join(' ')}`; + cronParser.parseExpression(normalized, { tz: 'UTC' }); + return normalized; }; -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; - }); -}; +export const cronToBackend = (expression: string) => + normalizeRecurrence(expression).replaceAll('L', '$'); -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; - }); -}; +export const cronFromBackend = (expression: string) => normalizeRecurrence(expression); /** * Extracts the template expense ID from a pg_cron job command. diff --git a/src/pages/recurring.tsx b/src/pages/recurring.tsx index 29a582a71..cf42fd0d2 100644 --- a/src/pages/recurring.tsx +++ b/src/pages/recurring.tsx @@ -4,12 +4,12 @@ import Link from 'next/link'; import { useCallback, useMemo } from 'react'; import { toast } from 'sonner'; +import { RecurrencePreview } from '~/components/Expense/RecurrencePreview'; import { EditRecurrenceDialog } from '~/components/Expense/EditRecurrenceDialog'; import MainLayout from '~/components/Layout/MainLayout'; import { SimpleConfirmationDialog } from '~/components/SimpleConfirmationDialog'; 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'; @@ -23,8 +23,6 @@ interface RecurringExpenseItemProps { onDelete: (recurrenceId: number) => Promise; isDeleting: boolean; toUIDate: (date: Date) => string; - cronParser: (cron: string) => string; - i18nReady: boolean; } const RecurringExpenseItem: React.FC = ({ @@ -32,8 +30,6 @@ const RecurringExpenseItem: React.FC = ({ onDelete, isDeleting, toUIDate, - cronParser, - i18nReady, }) => { const { t, getCurrencyHelpersCached } = useTranslationWithUtils(); const { toUIString, formatter } = getCurrencyHelpersCached(item.expense.currency); @@ -65,10 +61,9 @@ const RecurringExpenseItem: React.FC = ({ formatter.formatToParts(0).find(({ type }) => 'currency' === type)?.value ?? '', })}

-

- {t('recurrence.recurring')} - {i18nReady && schedule ? `: ${cronParser(schedule)}` : ''} -

+
+ {schedule ? : t('recurrence.recurring')} +

{toUIDate(item.expense.expenseDate)}

@@ -103,8 +98,6 @@ const RecurringPage: NextPageWithUser = () => { const deleteRecurrenceMutation = api.expense.deleteRecurrence.useMutation(); const apiUtils = api.useUtils(); - const { cronParser, i18nReady } = useIntlCronParser(); - const handleDelete = useCallback( async (recurrenceId: number) => { try { @@ -148,8 +141,6 @@ const RecurringPage: NextPageWithUser = () => { onDelete={handleDelete} isDeleting={deleteRecurrenceMutation.isPending} toUIDate={toUIDate} - cronParser={cronParser} - i18nReady={i18nReady} /> ))}
diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index c2565965a..316c3ab10 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -1,3 +1,8 @@ +import { cronToBackend } from '~/lib/cron'; +import { + getNextRecurrenceDate, + getRecurrenceSchedule, +} from '../services/recurrenceScheduleService'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { beforeCursor, paginationInput, toPage } from '~/utils/pagination'; @@ -27,6 +32,16 @@ import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; export const expenseRouter = createTRPCRouter({ + getRecurrencePreview: protectedProcedure + .input(z.object({ cronExpression: z.string().transform(cronToBackend) })) + .query(async ({ input }) => { + const [setting] = await db.$queryRaw<{ timeZone: string }[]>` + SELECT current_setting('cron.timezone') AS "timeZone" + `; + const timeZone = setting!.timeZone; + const schedule = getRecurrenceSchedule(input.cronExpression); + return { timeZone, schedule, nextRun: getNextRecurrenceDate(schedule, timeZone) }; + }), getCumulatedBalances: balanceProcedure.query(async ({ ctx }) => { const cumulatedBalances = await ctx.db.balanceView.groupBy({ by: ['currency'], @@ -524,7 +539,9 @@ export const expenseRouter = createTRPCRouter({ }), updateRecurrence: protectedProcedure - .input(z.object({ recurrenceId: z.number(), cronExpression: z.string() })) + .input( + z.object({ recurrenceId: z.number(), cronExpression: z.string().transform(cronToBackend) }), + ) .mutation(async ({ input, ctx }) => { const recurrence = await db.expenseRecurrence.findUnique({ where: { id: input.recurrenceId }, @@ -546,7 +563,7 @@ export const expenseRouter = createTRPCRouter({ } // Use cron.alter_job to update the schedule - await db.$executeRaw`SELECT cron.alter_job(${recurrence.job.jobid}, schedule := ${input.cronExpression})`; + await db.$executeRaw`SELECT cron.alter_job(${recurrence.job.jobid}, schedule := ${getRecurrenceSchedule(input.cronExpression)})`; }), deleteExpense: protectedProcedure diff --git a/src/server/api/services/recurrenceScheduleService.test.ts b/src/server/api/services/recurrenceScheduleService.test.ts new file mode 100644 index 000000000..7da4e6f4c --- /dev/null +++ b/src/server/api/services/recurrenceScheduleService.test.ts @@ -0,0 +1,100 @@ +import { env } from '~/env'; +import { db } from '~/server/db'; + +import { + getNextRecurrenceDate, + getRecurrenceSchedule, + reconcileRecurrenceSchedules, +} from './recurrenceScheduleService'; + +jest.mock('~/env', () => ({ env: { RECURRENCE_TIME: '00:00' } })); +jest.mock('~/server/db', () => ({ db: { $transaction: jest.fn() } })); + +describe('recurrence execution time', () => { + beforeEach(() => { + jest.replaceProperty(env, 'RECURRENCE_TIME', '00:00'); + }); + + it('defaults to midnight and preserves last-day rules', () => { + expect(getRecurrenceSchedule('15 22 L * *')).toBe('0 0 $ * *'); + }); + + it('applies a configured time without shifting calendar fields', () => { + jest.replaceProperty(env, 'RECURRENCE_TIME', '06:35'); + expect(getRecurrenceSchedule('0 0 1 1 *')).toBe('35 6 1 1 *'); + expect(getRecurrenceSchedule('0 0 $ * *')).toBe('35 6 $ * *'); + expect(getRecurrenceSchedule('0 0 * * 0')).toBe('35 6 * * 0'); + }); + + it('reconciles changed jobs and leaves matching schedules alone', async () => { + jest.replaceProperty(env, 'RECURRENCE_TIME', '06:35'); + const query = jest.fn().mockResolvedValue([ + { jobid: 1n, schedule: '0 0 $ * *' }, + { jobid: 2n, schedule: '35 6 1 1 *' }, + { jobid: 3n, schedule: '0 0 * * 0' }, + ]); + const execute = jest.fn().mockResolvedValue(1); + jest + .spyOn(db, '$transaction') + .mockImplementationOnce(async (callback: any) => + callback({ $queryRaw: query, $executeRaw: execute }), + ); + + expect(await reconcileRecurrenceSchedules()).toBe(2); + expect(query.mock.calls[0]![0].join('')).toContain('JOIN "ExpenseRecurrence"'); + expect(query.mock.calls[0]![0].join('')).toContain('FOR UPDATE OF j'); + expect(execute.mock.calls.map((call) => call.slice(1))).toEqual([ + [1n, '35 6 $ * *'], + [3n, '35 6 * * 0'], + ]); + }); + + it('does not alter jobs on a repeated startup with matching schedules', async () => { + const execute = jest.fn(); + jest.spyOn(db, '$transaction').mockImplementationOnce(async (callback: any) => + callback({ + $queryRaw: jest.fn().mockResolvedValue([{ jobid: 1n, schedule: '0 0 $ * *' }]), + $executeRaw: execute, + }), + ); + expect(await reconcileRecurrenceSchedules()).toBe(0); + expect(execute).not.toHaveBeenCalled(); + }); +}); + +describe('recurrence preview instants', () => { + it.each([ + ['2026-01-15T00:00:00Z', '2026-01-15T05:35:00.000Z'], + ['2026-07-15T00:00:00Z', '2026-07-15T04:35:00.000Z'], + ])('uses the server zone at the occurrence date (%s)', (now, expected) => { + expect(getNextRecurrenceDate('35 6 * * *', 'Europe/Warsaw', new Date(now)).toISOString()).toBe( + expected, + ); + }); + + it('preserves last-day semantics across February in a leap year', () => { + expect( + getNextRecurrenceDate( + '0 0 $ * *', + 'Asia/Tokyo', + new Date('2028-02-01T00:00:00Z'), + ).toISOString(), + ).toBe('2028-02-28T15:00:00.000Z'); + }); + + it('returns an instant in the previous UTC year when the server calendar is ahead', () => { + expect( + getNextRecurrenceDate( + '0 0 1 1 *', + 'Asia/Tokyo', + new Date('2026-12-01T00:00:00Z'), + ).toISOString(), + ).toBe('2026-12-31T15:00:00.000Z'); + }); + + it('uses the next occurrence after the configured execution time has passed', () => { + expect( + getNextRecurrenceDate('35 6 * * *', 'UTC', new Date('2026-01-15T06:36:00Z')).toISOString(), + ).toBe('2026-01-16T06:35:00.000Z'); + }); +}); diff --git a/src/server/api/services/recurrenceScheduleService.ts b/src/server/api/services/recurrenceScheduleService.ts new file mode 100644 index 000000000..2dea17ebd --- /dev/null +++ b/src/server/api/services/recurrenceScheduleService.ts @@ -0,0 +1,48 @@ +import cronParser from 'cron-parser'; + +import { env } from '~/env'; +import { cronToBackend } from '~/lib/cron'; +import { db } from '~/server/db'; + +/** Apply the operator's execution time in cron.timezone, preserving calendar fields. */ +export const getRecurrenceSchedule = (expression: string): string => { + const [hour, minute] = env.RECURRENCE_TIME.split(':').map(Number); + const calendar = cronToBackend(expression).split(' ').slice(2).join(' '); + return `${minute} ${hour} ${calendar}`; +}; + +/** Resolve a concrete instant so browser display includes date shifts and DST. */ +export const getNextRecurrenceDate = ( + schedule: string, + timeZone: string, + currentDate = new Date(), +): Date => + cronParser + .parseExpression(schedule.replaceAll('$', 'L'), { tz: timeZone, currentDate }) + .next() + .toDate(); + +/** Reconcile on every startup, including when only runtime configuration changes. */ +export const reconcileRecurrenceSchedules = async (): Promise => + db.$transaction(async (tx) => { + // Lock jobs so concurrent application instances observe the latest schedules. + const jobs = await tx.$queryRaw<{ jobid: bigint; schedule: string }[]>` + SELECT j.jobid, j.schedule FROM cron.job j + JOIN "ExpenseRecurrence" r ON r."jobId" = j.jobid + ORDER BY j.jobid FOR UPDATE OF j + `; + const changes = jobs + .map((job) => ({ + jobid: job.jobid, + schedule: job.schedule, + desired: getRecurrenceSchedule(job.schedule), + })) + .filter((job) => job.schedule !== job.desired); + + await Promise.all( + changes.map( + (job) => tx.$executeRaw`SELECT cron.alter_job(${job.jobid}, schedule := ${job.desired})`, + ), + ); + return changes.length; + }); diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index b6a3f1f9f..65e43933d 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -1,5 +1,7 @@ import { db } from '~/server/db'; +import { getRecurrenceSchedule } from './recurrenceScheduleService'; + export const createRecurringDeleteBankCacheJob = async ( cronExpression: string, interval: string, @@ -20,5 +22,5 @@ export const createRecurringExpenseJob = (expenseId: string, cronExpression: str db.$queryRawUnsafe<[{ schedule: bigint }]>( `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, expenseId, - cronExpression, + getRecurrenceSchedule(cronExpression), ); diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index dbd53d7b6..62c1017a8 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -7,6 +7,7 @@ import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; import type { CreateExpense } from '~/types/expense.types'; import { sendExpensePushNotification } from './notificationService'; import { createRecurringExpenseJob } from './scheduleService'; +import { getRecurrenceSchedule } from './recurrenceScheduleService'; import { getCurrencyHelpers } from '~/utils/numbers'; import { isCurrencyCode } from '~/lib/currency'; import { DEFAULT_CATEGORY } from '~/lib/category'; @@ -295,7 +296,7 @@ export async function editExpense( if (cronExpression && cronExpression !== currentSchedule) { // Schedule changed - update the cron job operations.push( - db.$executeRaw`SELECT cron.alter_job(${expense.recurrence.job.jobid}, schedule := ${cronExpression})`, + db.$executeRaw`SELECT cron.alter_job(${expense.recurrence.job.jobid}, schedule := ${getRecurrenceSchedule(cronExpression)})`, ); } else if (!cronExpression) { // Recurrence removed - unschedule and delete recurrence record diff --git a/src/tests/cronBuilder.test.tsx b/src/tests/cronBuilder.test.tsx new file mode 100644 index 000000000..16fb6937b --- /dev/null +++ b/src/tests/cronBuilder.test.tsx @@ -0,0 +1,87 @@ +/** @jest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CronBuilder } from '~/components/ui/cron-builder'; + +jest.mock('~/components/Expense/RecurrencePreview', () => ({ RecurrencePreview: () => null })); + +describe('CronBuilder custom calendar fields', () => { + it('shows only calendar fields when opening an existing custom schedule', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('* 1 1'); + }); + + it('preserves spaces while typing a complete custom calendar rule', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, '* '); + expect(input).toHaveValue('* '); + await user.type(input, '1 '); + expect(input).toHaveValue('* 1 '); + await user.type(input, '1'); + expect(input).toHaveValue('* 1 1'); + expect(onChange).toHaveBeenLastCalledWith('0 0 * 1 1'); + }); + + it('rejects extra fields instead of silently dropping one', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, '1 * 1 1'); + expect(input).toHaveValue('1 * 1 1'); + expect(onChange).toHaveBeenLastCalledWith(''); + }); +}); + +describe('CronBuilder calendar tiles', () => { + it('selects weekdays without a dropdown', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + await user.click(screen.getByText('Mon', { selector: 'button' })); + expect(onChange).toHaveBeenLastCalledWith('0 0 * * 1'); + expect(screen.getByText('Mon', { selector: 'button' })).toHaveAttribute('data-state', 'on'); + await user.click(screen.getByText('Mon', { selector: 'button' })); + expect(onChange).toHaveBeenLastCalledWith('0 0 * * 1'); + }); + + it('selects the last day of the month', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByText('Last day', { selector: 'button' })); + expect(onChange).toHaveBeenLastCalledWith('0 0 L * *'); + }); + + it('selects a month and day for yearly recurrence', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByText('Aug', { selector: 'button' })); + await user.click(screen.getByText('25', { selector: 'button' })); + expect(onChange).toHaveBeenLastCalledWith('0 0 25 8 *'); + }); + it('shows only valid February days and clamps a previously selected 31st', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByText('Feb', { selector: 'button' })); + expect(screen.queryByText('30', { selector: 'button' })).not.toBeInTheDocument(); + expect(screen.queryByText('31', { selector: 'button' })).not.toBeInTheDocument(); + expect(screen.getByText('29', { selector: 'button' })).toHaveAttribute('data-state', 'on'); + expect(onChange).toHaveBeenLastCalledWith('0 0 29 2 *'); + await user.click(screen.getByText('Last day', { selector: 'button' })); + await user.click(screen.getByText('Apr', { selector: 'button' })); + expect(screen.getByText('30', { selector: 'button' })).toBeInTheDocument(); + expect(screen.queryByText('31', { selector: 'button' })).not.toBeInTheDocument(); + expect(onChange).toHaveBeenLastCalledWith('0 0 L 4 *'); + }); +}); diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts index 9906c1b88..db2fdc6c4 100644 --- a/src/tests/integration/recurrence.integration.test.ts +++ b/src/tests/integration/recurrence.integration.test.ts @@ -1,3 +1,6 @@ +import { env } from '~/env'; +import { reconcileRecurrenceSchedules } from '~/server/api/services/recurrenceScheduleService'; + import { db } from './database'; import { testScenario } from './factories'; import { callerFor } from './trpc'; @@ -8,6 +11,7 @@ describe('recurrence lifecycle', () => { const caller = callerFor(owner.id); const [template] = await caller.expense.addOrEditExpense({ ...input, + expenseDate: new Date('2000-01-01T12:00:00Z'), cronExpression: '0 0 1 1 *', }); const recurrence = await db.expenseRecurrence.findFirstOrThrow({ include: { job: true } }); @@ -37,7 +41,7 @@ describe('recurrence lifecycle', () => { cronExpression: '0 12 1 1 *', }); expect(await db.job.findUnique({ where: { jobid: recurrence.jobId } })).toMatchObject({ - schedule: '0 12 1 1 *', + schedule: '0 0 1 1 *', }); await caller.expense.deleteExpense({ expenseId: duplicate.id }); expect(await db.job.count()).toBe(1); @@ -48,4 +52,95 @@ describe('recurrence lifecycle', () => { expect(await db.job.count()).toBe(0); expect(await db.expenseRecurrence.count()).toBe(0); }); + it('skips a future initial expense and retries only once per calendar day', async () => { + const { owner, input } = await testScenario(); + const caller = callerFor(owner.id); + const [template] = await caller.expense.addOrEditExpense({ + ...input, + expenseDate: new Date('2099-07-01T12:00:00Z'), + cronExpression: '0 0 L * *', + }); + const duplicate = () => db.$queryRaw<{ id: string | null }[]>` + SELECT duplicate_expense_with_participants(${template!.id}::uuid)::text AS id + `; + expect((await duplicate())[0]!.id).toBeNull(); + await db.$executeRaw` + UPDATE "Expense" SET "expenseDate" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE id = ${template!.id}::uuid + `; + expect((await duplicate())[0]!.id).toBeNull(); + await db.expense.update({ + where: { id: template!.id }, + data: { expenseDate: new Date('2000-01-01T12:00:00Z') }, + }); + const results = await Promise.all([duplicate(), duplicate()]); + expect(results.flat().filter((result) => null !== result.id)).toHaveLength(1); + expect((await duplicate())[0]!.id).toBeNull(); + const generated = results.flat().find((result) => null !== result.id)!; + await caller.expense.deleteExpense({ expenseId: generated.id! }); + expect((await duplicate())[0]!.id).toBeNull(); + const recurrence = await db.expenseRecurrence.findFirstOrThrow({ include: { job: true } }); + expect(recurrence.job.schedule).toBe('0 0 $ * *'); + }); + it('reconciles existing schedules and applies the configured time to new and edited rules', async () => { + const { owner, input } = await testScenario(); + const caller = callerFor(owner.id); + await caller.expense.addOrEditExpense({ ...input, cronExpression: '0 0 L * *' }); + const recurrence = await db.expenseRecurrence.findFirstOrThrow(); + await db.$executeRaw`SELECT cron.alter_job(${recurrence.jobId}, active := false)`; + const [maintenance] = await db.$queryRaw<{ id: bigint }[]>` + SELECT cron.schedule('test_maintenance', '15 2 * * *', 'SELECT 1') AS id + `; + const timeSetting = jest.replaceProperty(env, 'RECURRENCE_TIME', '06:35'); + try { + expect(await reconcileRecurrenceSchedules()).toBe(1); + expect(await reconcileRecurrenceSchedules()).toBe(0); + expect(await db.job.findUnique({ where: { jobid: recurrence.jobId } })).toMatchObject({ + schedule: '35 6 $ * *', + active: false, + }); + expect(await db.job.findUnique({ where: { jobid: maintenance!.id } })).toMatchObject({ + schedule: '15 2 * * *', + }); + await caller.expense.updateRecurrence({ + recurrenceId: recurrence.id, + cronExpression: '0 0 1 1 *', + }); + expect(await db.job.findUnique({ where: { jobid: recurrence.jobId } })).toMatchObject({ + schedule: '35 6 1 1 *', + active: false, + }); + const [newTemplate] = await caller.expense.addOrEditExpense({ + ...input, + cronExpression: '0 0 * * 0', + }); + const newRecurrence = await db.expenseRecurrence.findFirstOrThrow({ + where: { expense: { some: { id: newTemplate!.id } } }, + include: { job: true }, + }); + expect(newRecurrence.job.schedule).toBe('35 6 * * 0'); + const preview = await caller.expense.getRecurrencePreview({ cronExpression: '0 0 L * *' }); + expect(preview).toMatchObject({ timeZone: 'UTC', schedule: '35 6 $ * *' }); + expect(preview.nextRun.getTime()).toBeGreaterThan(Date.now()); + } finally { + timeSetting.restore(); + } + }); + it.each(['Pacific/Kiritimati', 'America/Los_Angeles', 'Asia/Kathmandu'])( + 'keeps the instance calendar when the request time zone is %s', + async (timeZone) => { + const { owner, input } = await testScenario(); + const caller = callerFor(owner.id, timeZone); + await caller.expense.addOrEditExpense({ ...input, cronExpression: '0 0 L * *' }); + const recurrence = await db.expenseRecurrence.findFirstOrThrow({ include: { job: true } }); + expect(recurrence.job.schedule).toBe('0 0 $ * *'); + const preview = await caller.expense.getRecurrencePreview({ cronExpression: '0 0 L * *' }); + expect(preview).toMatchObject({ timeZone: 'UTC', schedule: '0 0 $ * *' }); + expect(preview.nextRun.getUTCHours()).toBe(0); + const lastDay = new Date( + Date.UTC(preview.nextRun.getUTCFullYear(), preview.nextRun.getUTCMonth() + 1, 0), + ).getUTCDate(); + expect(preview.nextRun.getUTCDate()).toBe(lastDay); + }, + ); }); diff --git a/src/tests/recurrenceEnv.test.ts b/src/tests/recurrenceEnv.test.ts new file mode 100644 index 000000000..461ae34c3 --- /dev/null +++ b/src/tests/recurrenceEnv.test.ts @@ -0,0 +1,23 @@ +import { type env as AppEnv } from '~/env'; + +describe('recurrence execution time with skipped environment validation', () => { + afterEach(() => jest.restoreAllMocks()); + + it.each([ + [undefined, '00:00'], + ['', '00:00'], + ['06:35', '06:35'], + ])('resolves %p to %s', (value, expected) => { + const testEnvironment: NodeJS.ProcessEnv = { ...process.env, SKIP_ENV_VALIDATION: '1' }; + delete testEnvironment.RECURRENCE_TIME; + if (undefined !== value) { + testEnvironment.RECURRENCE_TIME = value; + } + jest.replaceProperty(process, 'env', testEnvironment); + + jest.isolateModules(() => { + const { env } = require('~/env') as { env: typeof AppEnv }; + expect(env.RECURRENCE_TIME).toBe(expected); + }); + }); +}); diff --git a/src/tests/recurrencePreview.test.tsx b/src/tests/recurrencePreview.test.tsx new file mode 100644 index 000000000..40aed0b2b --- /dev/null +++ b/src/tests/recurrencePreview.test.tsx @@ -0,0 +1,129 @@ +/** @jest-environment jsdom */ + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { RecurrencePreview } from '~/components/Expense/RecurrencePreview'; +import common from '../../public/locales/en/common.json'; + +const mockPreview = { + data: undefined as { nextRun: Date; timeZone: string; schedule: string } | undefined, + isError: false, +}; + +jest.mock('~/utils/api', () => ({ + api: { + expense: { + getRecurrencePreview: { useQuery: () => mockPreview }, + }, + }, +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + i18n: { language: 'en-US' }, + t: (key: string, values: Record = {}) => { + const translations = jest.requireActual('../../public/locales/en/common.json'); + const text = translations.recurrence[key.split('.')[1]!]; + return Object.entries(values).reduce( + (result, [name, value]) => result.replaceAll(`{${name}}`, value), + text, + ); + }, + }), +})); +jest.mock('~/hooks/useIntlCronParser', () => ({ + useIntlCronParser: () => ({ cronParser: (schedule: string) => schedule }), +})); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe('RecurrencePreview', () => { + beforeEach(() => { + mockPreview.data = undefined; + mockPreview.isError = false; + const options = new Intl.DateTimeFormat().resolvedOptions(); + jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ + ...options, + timeZone: 'America/Los_Angeles', + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + const renderPreview = async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + return { + container, + cleanup: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; + }; + + it('shows the browser-local date and time, including the previous year', async () => { + mockPreview.data = { + nextRun: new Date('2027-01-01T02:35:00Z'), + timeZone: 'UTC', + schedule: '35 2 1 1 *', + }; + const { container, cleanup } = await renderPreview(); + try { + expect(container.textContent).toContain('Thursday, December 31, 2026 at 6:35 PM'); + expect(container.textContent).toContain('America/Los_Angeles, your time zone'); + expect(container.textContent).toContain('Schedule: 35 2 1 1 * (UTC)'); + expect(container.textContent).not.toContain('cron('); + } finally { + await cleanup(); + } + }); + + it('uses the browser offset for the occurrence date, including daylight saving', async () => { + mockPreview.data = { + nextRun: new Date('2027-07-01T02:35:00Z'), + timeZone: 'UTC', + schedule: '35 2 1 * *', + }; + const { container, cleanup } = await renderPreview(); + try { + expect(container.textContent).toContain('Wednesday, June 30, 2027 at 7:35 PM'); + } finally { + await cleanup(); + } + }); + + it('does not format a server-rendered preview in the server process time zone', () => { + mockPreview.data = { + nextRun: new Date('2027-01-01T02:35:00Z'), + timeZone: 'UTC', + schedule: '35 2 1 1 *', + }; + const html = renderToStaticMarkup(); + expect(html).toContain(common.recurrence.execution_time_loading); + expect(html).not.toContain('2027'); + expect(html).not.toContain('UTC'); + }); + + it('shows loading instead of a misleading midnight default', async () => { + const { container, cleanup } = await renderPreview(); + try { + expect(container.textContent).toBe(common.recurrence.execution_time_loading); + } finally { + await cleanup(); + } + }); + + it('shows an explicit error if execution settings cannot be fetched', async () => { + mockPreview.isError = true; + const { container, cleanup } = await renderPreview(); + try { + expect(container.textContent).toBe(common.recurrence.execution_time_unavailable); + } finally { + await cleanup(); + } + }); +}); diff --git a/src/tests/recurring.test.tsx b/src/tests/recurring.test.tsx index e90199009..806dc1b70 100644 --- a/src/tests/recurring.test.tsx +++ b/src/tests/recurring.test.tsx @@ -32,6 +32,7 @@ jest.mock('~/utils/api', () => ({ api: { expense: { getRecurringExpenses: { useQuery: () => ({ data: mockRecurrences, isPending: false }) }, + getRecurrencePreview: { useQuery: () => ({ data: undefined, isError: false }) }, deleteRecurrence: { useMutation: () => ({ isPending: false }) }, }, useUtils: () => ({ expense: { getRecurringExpenses: {} } }), diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 8ead7d236..f2a0e0bfd 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -1,3 +1,4 @@ +import { cronToBackend } from '~/lib/cron'; import { type Expense, type ExpenseParticipant, SplitType } from '@prisma/client'; import { type ZodTypeAny, z } from 'zod'; @@ -53,7 +54,10 @@ export const createExpenseSchema = z.object({ expenseDate: z.date().optional(), expenseId: z.string().optional(), conversionToId: z.string().optional(), - cronExpression: z.string().optional(), + cronExpression: z + .string() + .transform((value) => (value ? cronToBackend(value) : '')) + .optional(), }) satisfies z.ZodType; export const createCurrencyConversionSchema = z.object({