feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules A yearly or quarterly recurring schedule had no way to say which month it bills in: the dialog exposed interval and day of month only, so a yearly schedule created in September always fired in September. The phase of a schedule is fully defined by its first run date, which the table already stores as next_run_date and the create API already accepted as start_date but nothing exposed. - Dialog: new date field (first invoice date on create, next invoice date on edit), prefilled with the next natural occurrence so the default is "no offset"; kept in step with day of month both ways; shows the following three run dates so the phase is visible. Sent as start_date on create and as next_run_date on edit only when the user actually re-phased. - API: create validates start_date (on the day_of_month grid, not in the past); update accepts next_run_date (on the grid for the effective day, strictly after today in Stockholm) and lets it win over the automatic recompute a day change or reactivation does. - Staged operations / MCP: start_date documented as the phase; update tool gains next_run_date. Commit executor rejects off-grid dates and rolls a date that went stale before approval forward on its own grid. - lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared by the dialog, the routes, the executors and the cron service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC * fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog Resolves the skeptic and CI findings on #2338 in one pass: - MCP staging tools now apply the same grid and past/future rules as the routes to start_date and next_run_date, so the preview a human approves is exactly what the commit executor writes (previously an off-grid date staged fine and failed at approval, and a past next_run_date was rolled to another date silently). - The dialog computes today and the default first invoice date in Europe/Stockholm instead of the browser's zone, matching the server; getStockholmDateHour moved to the client-safe module and is re-exported from the service. - gnubok_update_recurring_schedule description trimmed under the 280-char limit while keeping the clamping and Stockholm phrases the registration test requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0c854ac54f
commit
238cbe13f9
+8
-1
@@ -876,7 +876,9 @@ export const CreateRecurringScheduleSchema = z.object({
|
||||
// Copied onto invoices.default_dimensions for every generated invoice.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Optional: when to first run. Defaults to next occurrence of day_of_month
|
||||
// (today if day_of_month === today, otherwise next month).
|
||||
// (today if day_of_month === today, otherwise next month). Fixes the month
|
||||
// phase of a quarterly/yearly schedule ("bill in February"); must be on the
|
||||
// schedule grid (day = day_of_month clamped) and not in the past.
|
||||
start_date: isoDate.optional(),
|
||||
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
@@ -896,6 +898,11 @@ export const UpdateRecurringScheduleSchema = z.object({
|
||||
notes: z.string().nullable().optional(),
|
||||
auto_send: z.boolean().optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
// Explicit next run date: re-phases the schedule (e.g. move a yearly
|
||||
// schedule from January to February). Must be on the schedule grid for
|
||||
// the effective day_of_month and strictly after today in Stockholm; wins
|
||||
// over the automatic recompute a day_of_month edit or reactivation does.
|
||||
next_run_date: isoDate.optional(),
|
||||
// Replaces the whole bag if provided ({} clears all tags). Omit to keep.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Replace all items if provided. Omit to keep existing items unchanged.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
alignRunDateToDay,
|
||||
projectRunDates,
|
||||
runDateMatchesDayOfMonth,
|
||||
} from '../recurring-run-date'
|
||||
|
||||
describe('runDateMatchesDayOfMonth', () => {
|
||||
it('accepts a date on the exact day', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-15', 15)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the clamped last day in a shorter month', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-28', 31)).toBe(true)
|
||||
expect(runDateMatchesDayOfMonth('2028-02-29', 31)).toBe(true)
|
||||
expect(runDateMatchesDayOfMonth('2027-04-30', 31)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a day that is not where day_of_month lands', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-14', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-03-30', 31)).toBe(false)
|
||||
// 28 is not the clamp of 31 in a 31-day month.
|
||||
expect(runDateMatchesDayOfMonth('2027-01-28', 31)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed or calendar-invalid dates', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-2-15', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-13-15', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-02-31', 31)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('', 15)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignRunDateToDay', () => {
|
||||
it('moves the day within the same month', () => {
|
||||
expect(alignRunDateToDay('2027-02-15', 20)).toBe('2027-02-20')
|
||||
})
|
||||
|
||||
it('clamps to the last day of the month', () => {
|
||||
expect(alignRunDateToDay('2027-02-15', 31)).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('returns the input unchanged when it is not a date', () => {
|
||||
expect(alignRunDateToDay('', 20)).toBe('')
|
||||
expect(alignRunDateToDay('nope', 20)).toBe('nope')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectRunDates', () => {
|
||||
it('projects a yearly schedule on its February phase', () => {
|
||||
expect(projectRunDates('2027-02-15', 15, 12, 3)).toEqual([
|
||||
'2027-02-15',
|
||||
'2028-02-15',
|
||||
'2029-02-15',
|
||||
])
|
||||
})
|
||||
|
||||
it('projects a quarterly schedule and clamps day 31', () => {
|
||||
expect(projectRunDates('2027-02-28', 31, 3, 4)).toEqual([
|
||||
'2027-02-28',
|
||||
'2027-05-31',
|
||||
'2027-08-31',
|
||||
'2027-11-30',
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls over the year boundary for a monthly schedule', () => {
|
||||
expect(projectRunDates('2027-11-05', 5, 1, 3)).toEqual([
|
||||
'2027-11-05',
|
||||
'2027-12-05',
|
||||
'2028-01-05',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for an unparseable first date', () => {
|
||||
expect(projectRunDates('', 15, 12, 3)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pure date helpers for recurring invoice schedules that are safe to import
|
||||
* from client components (no Supabase, PDF or email imports). The cron-side
|
||||
* service (recurring-schedule-service.ts) builds on the same primitives so
|
||||
* the dialog, the routes and the cron agree on what "the schedule's grid"
|
||||
* means: every run date has day = min(day_of_month, last day of that month),
|
||||
* and the month phase is fixed by the first run date.
|
||||
*/
|
||||
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
|
||||
/** Last day of the month (1-indexed result, 0-indexed month). */
|
||||
export function lastDayOfMonth(year: number, monthIndex0: number): number {
|
||||
// Day 0 of next month = last day of this month.
|
||||
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
export function isoFromParts(year: number, monthIndex0: number, day: number): string {
|
||||
const yyyy = year.toString().padStart(4, '0')
|
||||
const mm = (monthIndex0 + 1).toString().padStart(2, '0')
|
||||
const dd = day.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/** Calendar-validated parts of a yyyy-mm-dd string, or null. */
|
||||
export function parseIsoDate(iso: string): { year: number; month0: number; day: number } | null {
|
||||
if (!ISO_DATE_RE.test(iso)) return null
|
||||
const year = Number(iso.slice(0, 4))
|
||||
const month0 = Number(iso.slice(5, 7)) - 1
|
||||
const day = Number(iso.slice(8, 10))
|
||||
if (month0 < 0 || month0 > 11 || day < 1 || day > lastDayOfMonth(year, month0)) return null
|
||||
return { year, month0, day }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `iso` is a calendar-valid date whose day is exactly where the
|
||||
* schedule's day_of_month lands in that month (31 -> 28/29/30 in shorter
|
||||
* months). A run date off the grid would make the cron's "advance one
|
||||
* interval from the due date" step drift to day_of_month on the next run,
|
||||
* so both write paths refuse it instead of silently normalizing.
|
||||
*/
|
||||
export function runDateMatchesDayOfMonth(iso: string, dayOfMonth: number): boolean {
|
||||
const parsed = parseIsoDate(iso)
|
||||
if (!parsed) return false
|
||||
return parsed.day === Math.min(dayOfMonth, lastDayOfMonth(parsed.year, parsed.month0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Same year-month as `iso`, day moved onto the schedule grid for
|
||||
* day_of_month. Used by the dialog to keep the date field in step when the
|
||||
* user edits the day. Returns `iso` unchanged when it is not parseable.
|
||||
*/
|
||||
export function alignRunDateToDay(iso: string, dayOfMonth: number): string {
|
||||
const parsed = parseIsoDate(iso)
|
||||
if (!parsed) return iso
|
||||
return isoFromParts(
|
||||
parsed.year,
|
||||
parsed.month0,
|
||||
Math.min(dayOfMonth, lastDayOfMonth(parsed.year, parsed.month0)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `count` run dates starting at `firstIso` and stepping
|
||||
* interval_months on the grid. Preview only (the cron computes each next
|
||||
* date from the actual due date); returns [] for an unparseable first date.
|
||||
*/
|
||||
export function projectRunDates(
|
||||
firstIso: string,
|
||||
dayOfMonth: number,
|
||||
intervalMonths: number,
|
||||
count: number,
|
||||
): string[] {
|
||||
const parsed = parseIsoDate(firstIso)
|
||||
if (!parsed || count <= 0) return []
|
||||
const out: string[] = [firstIso]
|
||||
let { year, month0 } = parsed
|
||||
while (out.length < count) {
|
||||
const m = month0 + intervalMonths
|
||||
year += Math.floor(m / 12)
|
||||
month0 = m % 12
|
||||
out.push(isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0))))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
||||
* for a given instant. The recurring cron runs in UTC on Vercel and the
|
||||
* dialog runs in whatever zone the browser is in, but users pick dates and a
|
||||
* send hour in Swedish local time, so every surface asks "what day and hour
|
||||
* is it in Sweden right now" through this one function. Uses Intl (DST-aware,
|
||||
* no extra dependency); en-CA + hourCycle 'h23' guarantees zero-padded
|
||||
* ISO-shaped parts and a 0-23 hour.
|
||||
*/
|
||||
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
return {
|
||||
date: `${get('year')}-${get('month')}-${get('day')}`,
|
||||
hour: Number(get('hour')),
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ import {
|
||||
import { snapshotInvoicePayee } from '@/lib/invoices/invoice-payee'
|
||||
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { lastDayOfMonth, isoFromParts } from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
// Lives in the client-safe module now (the dialog needs Stockholm's calendar
|
||||
// day too); re-exported so the cron, routes and executors keep importing it
|
||||
// from here.
|
||||
export { getStockholmDateHour } from '@/lib/invoices/recurring-run-date'
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
@@ -74,22 +80,6 @@ export interface ExecuteResult {
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Last day of the month for the given year/month (1-indexed month).
|
||||
* Used to clamp day_of_month values >28 in shorter months.
|
||||
*/
|
||||
function lastDayOfMonth(year: number, monthIndex0: number): number {
|
||||
// Day 0 of next month = last day of this month.
|
||||
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function isoFromParts(year: number, monthIndex0: number, day: number): string {
|
||||
const yyyy = year.toString().padStart(4, '0')
|
||||
const mm = (monthIndex0 + 1).toString().padStart(2, '0')
|
||||
const dd = day.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function assertValidCadence(dayOfMonth: number, intervalMonths: number): void {
|
||||
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
||||
@@ -204,29 +194,6 @@ export function computeInitialRunDate(
|
||||
return computeNextRunDate(today, dayOfMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
||||
* for a given instant. The recurring cron runs in UTC on Vercel, but users
|
||||
* pick a send time in Swedish local time, so we need "what day and hour is it
|
||||
* in Sweden right now". Uses Intl (DST-aware, no extra dependency); en-CA +
|
||||
* hourCycle 'h23' guarantees zero-padded ISO-shaped parts and a 0-23 hour.
|
||||
*/
|
||||
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
return {
|
||||
date: `${get('year')}-${get('month')}-${get('day')}`,
|
||||
hour: Number(get('hour')),
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteScheduleOptions {
|
||||
/**
|
||||
* Defence-in-depth sandbox suppression (ASVS V2.3): callers that resolved
|
||||
|
||||
@@ -173,6 +173,24 @@ describe('commitPendingOperation: create_recurring_schedule', () => {
|
||||
expect(result.error).toMatch(/email/i)
|
||||
})
|
||||
|
||||
it('rejects a start_date off the day_of_month grid at commit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
enqueue({ data: { id: CUSTOMER_ID, email: null } }) // customer
|
||||
enqueue({ data: null }) // status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('create_recurring_schedule', { ...createParams, start_date: '2999-09-24' }),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/day_of_month/)
|
||||
})
|
||||
|
||||
it('rejects tampered params at the commit boundary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
@@ -347,6 +365,77 @@ describe('commitPendingOperation: update_recurring_schedule', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('writes an explicit future next_run_date verbatim and skips the recompute', async () => {
|
||||
const { supabase, updates } = createCapturingSupabase([
|
||||
{ data: { id: 'op-recurring-1' }, error: null }, // claim
|
||||
{ data: { ...existingRow, interval_months: 12 }, error: null },
|
||||
{ data: null, error: null }, // schedule update
|
||||
{ data: null, error: null }, // finalize
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { day_of_month: 20, next_run_date: '2999-02-20' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(updates.recurring_invoice_schedules).toEqual([
|
||||
{ day_of_month: 20, next_run_date: '2999-02-20' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls a next_run_date that went stale before approval forward on its own grid', async () => {
|
||||
const { supabase, updates } = createCapturingSupabase([
|
||||
{ data: { id: 'op-recurring-1' }, error: null }, // claim
|
||||
{ data: { ...existingRow, interval_months: 12 }, error: null },
|
||||
{ data: null, error: null }, // schedule update
|
||||
{ data: null, error: null }, // finalize
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { next_run_date: '2020-02-25' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
const next = String(updates.recurring_invoice_schedules?.[0]?.next_run_date)
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
// Strictly future, and still a February 25 (the chosen phase survives).
|
||||
expect(next > todayStockholm).toBe(true)
|
||||
expect(next.slice(5)).toBe('02-25')
|
||||
})
|
||||
|
||||
it('rejects a next_run_date off the day_of_month grid at commit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
enqueue({ data: existingRow }) // existing
|
||||
enqueue({ data: null }) // status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { next_run_date: '2999-02-24' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/day_of_month/)
|
||||
})
|
||||
|
||||
it('rejects enabling auto_send at commit when the customer has no email', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
|
||||
import {
|
||||
buildInvoiceWriteData,
|
||||
@@ -793,6 +794,19 @@ async function commitCreateRecurringSchedule(
|
||||
}
|
||||
}
|
||||
|
||||
// Same grid rule as the create route. A start_date that turned stale
|
||||
// between staging and approval is NOT rejected: the cron rolls a missed
|
||||
// date forward on the grid, so the phase the user chose still holds.
|
||||
if (
|
||||
validated.start_date !== undefined &&
|
||||
!runDateMatchesDayOfMonth(validated.start_date, validated.day_of_month)
|
||||
) {
|
||||
return {
|
||||
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunDate = computeInitialRunDate(
|
||||
new Date(),
|
||||
validated.day_of_month,
|
||||
@@ -936,7 +950,11 @@ async function commitUpdateRecurringSchedule(
|
||||
// next STRICTLY-future occurrence, never today, so an approval cannot
|
||||
// trigger a same-hour surprise send. Editing other fields leaves
|
||||
// next_run_date alone so an unrelated edit never skips an imminent send.
|
||||
if (changes.status === 'active' || changes.day_of_month !== undefined) {
|
||||
if (
|
||||
changes.status === 'active' ||
|
||||
changes.day_of_month !== undefined ||
|
||||
changes.next_run_date !== undefined
|
||||
) {
|
||||
const reactivating = changes.status === 'active'
|
||||
const dayChanged =
|
||||
changes.day_of_month !== undefined && changes.day_of_month !== existing.day_of_month
|
||||
@@ -945,8 +963,31 @@ async function commitUpdateRecurringSchedule(
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
// An explicit next_run_date re-phases the schedule and wins over the
|
||||
// recompute. Grid mismatch is rejected like the PATCH route; a date that
|
||||
// went stale while the operation waited for approval is rolled forward
|
||||
// on its own grid (strictly future) instead of auto-rejecting, so the
|
||||
// approved phase survives a slow approval.
|
||||
if (changes.next_run_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(changes.next_run_date, effectiveDay)) {
|
||||
return {
|
||||
error: 'next_run_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
updateRow.next_run_date =
|
||||
changes.next_run_date <= todayStockholm
|
||||
? rollNextRunDateForward(
|
||||
changes.next_run_date,
|
||||
stockholmToday,
|
||||
effectiveDay,
|
||||
effectiveInterval,
|
||||
)
|
||||
: changes.next_run_date
|
||||
}
|
||||
|
||||
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
||||
if (staleOnReactivate || dayChanged) {
|
||||
if (changes.next_run_date === undefined && (staleOnReactivate || dayChanged)) {
|
||||
if (effectiveInterval === 1) {
|
||||
// Monthly keeps its long-standing today-anchored semantics.
|
||||
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
||||
|
||||
Reference in New Issue
Block a user