This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import { CronExpressionParser } from 'cron-parser'
// Convert a UTC date from cron-parser to user timezone representation
// This ensures consistency with other execution time calculations
const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => {
// Get the time string in the target timezone
const userTimeStr = utcDate.toLocaleString('en-CA', {
timeZone: timezone,
hour12: false,
})
const [dateStr, timeStr] = userTimeStr.split(', ')
const [year, month, day] = dateStr.split('-').map(Number)
const [hour, minute, second] = timeStr.split(':').map(Number)
// Create a new Date object representing this time as "local" time
// This matches the behavior expected by the execution-time-calculator
return new Date(year, month - 1, day, hour, minute, second)
}
/**
* Parse a cron expression and return the next 5 execution times
*
* @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek)
* @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York')
* @returns Array of Date objects representing the next 5 execution times
*/
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
if (!cronExpression || cronExpression.trim() === '')
return []
const parts = cronExpression.trim().split(/\s+/)
// Support both 5-field format and predefined expressions
if (parts.length !== 5 && !cronExpression.startsWith('@'))
return []
try {
// Parse the cron expression with timezone support
// Use the actual current time for cron-parser to handle properly
const interval = CronExpressionParser.parse(cronExpression, {
tz: timezone,
})
// Get the next 5 execution times using the take() method
const nextCronDates = interval.take(5)
// Convert CronDate objects to Date objects and ensure they represent
// the time in user timezone (consistent with execution-time-calculator.ts)
return nextCronDates.map((cronDate) => {
const utcDate = cronDate.toDate()
return convertToUserTimezoneRepresentation(utcDate, timezone)
})
}
catch {
// Return empty array if parsing fails
return []
}
}
/**
* Validate a cron expression format and syntax
*
* @param cronExpression - Standard 5-field cron expression to validate
* @returns boolean indicating if the cron expression is valid
*/
export const isValidCronExpression = (cronExpression: string): boolean => {
if (!cronExpression || cronExpression.trim() === '')
return false
const parts = cronExpression.trim().split(/\s+/)
// Support both 5-field format and predefined expressions
if (parts.length !== 5 && !cronExpression.startsWith('@'))
return false
try {
// Use cron-parser to validate the expression
CronExpressionParser.parse(cronExpression)
return true
}
catch {
return false
}
}

View File

@@ -0,0 +1,295 @@
import type { ScheduleTriggerNodeType } from '../types'
import { isValidCronExpression, parseCronExpression } from './cron-parser'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
const DEFAULT_TIMEZONE = 'UTC'
const resolveTimezone = (timezone?: string): string => {
if (timezone)
return timezone
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE
}
catch {
return DEFAULT_TIMEZONE
}
}
// Get current time completely in user timezone, no browser timezone involved
const getUserTimezoneCurrentTime = (timezone?: string): Date => {
const targetTimezone = resolveTimezone(timezone)
const now = new Date()
const userTimeStr = now.toLocaleString('en-CA', {
timeZone: targetTimezone,
hour12: false,
})
const [dateStr, timeStr] = userTimeStr.split(', ')
const [year, month, day] = dateStr.split('-').map(Number)
const [hour, minute, second] = timeStr.split(':').map(Number)
return new Date(year, month - 1, day, hour, minute, second)
}
// Format date that is already in user timezone, no timezone conversion
const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
const dateOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
}
if (includeWeekday)
dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name
const timeOptions: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}
const dateStr = date.toLocaleDateString('en-US', dateOptions)
const timeStr = date.toLocaleTimeString('en-US', timeOptions)
if (includeTimezone) {
const timezoneOffset = convertTimezoneToOffsetStr(timezone)
return `${dateStr}, ${timeStr} (${timezoneOffset})`
}
return `${dateStr}, ${timeStr}`
}
// Helper function to get default datetime - consistent with base DatePicker
export const getDefaultDateTime = (): Date => {
const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0)
return defaultDate
}
export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => {
const timezone = resolveTimezone(data.timezone)
if (data.mode === 'cron') {
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
return []
return parseCronExpression(data.cron_expression, timezone).slice(0, count)
}
const times: Date[] = []
const defaultTime = data.visual_config?.time || '12:00 AM'
// Get "today" in user's timezone for display purposes
const now = new Date()
const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: timezone })
const [year, month, day] = userTodayStr.split('-').map(Number)
const userToday = new Date(year, month - 1, day, 0, 0, 0, 0)
if (data.frequency === 'hourly') {
const onMinute = data.visual_config?.on_minute ?? 0
// Get current time completely in user timezone
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
let hour = userCurrentTime.getHours()
if (userCurrentTime.getMinutes() >= onMinute)
hour += 1 // Start from next hour if current minute has passed
for (let i = 0; i < count; i++) {
const execution = new Date(userToday)
execution.setHours(hour + i, onMinute, 0, 0)
// Handle day overflow
if (hour + i >= 24) {
execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24))
execution.setHours((hour + i) % 24, onMinute, 0, 0)
}
times.push(execution)
}
}
else if (data.frequency === 'daily') {
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
// Check if today's configured time has already passed
const todayExecution = new Date(userToday)
todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0)
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
const startOffset = todayExecution <= userCurrentTime ? 1 : 0
for (let i = 0; i < count; i++) {
const execution = new Date(userToday)
execution.setDate(userToday.getDate() + startOffset + i)
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
times.push(execution)
}
}
else if (data.frequency === 'weekly') {
const selectedDays = data.visual_config?.weekdays || ['sun']
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
// Get current time completely in user timezone
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
let executionCount = 0
let weekOffset = 0
while (executionCount < count) {
let hasValidDays = false
for (const selectedDay of selectedDays) {
if (executionCount >= count) break
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
if (targetDay === undefined) continue
hasValidDays = true
const currentDayOfWeek = userToday.getDay()
const daysUntilTarget = (targetDay - currentDayOfWeek + 7) % 7
// Check if today's configured time has already passed
const todayAtTargetTime = new Date(userToday)
todayAtTargetTime.setHours(displayHour, Number.parseInt(minute), 0, 0)
let adjustedDays = daysUntilTarget
if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime)
adjustedDays = 7
const execution = new Date(userToday)
execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7))
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
// Only add if execution time is in the future
if (execution > userCurrentTime) {
times.push(execution)
executionCount++
}
}
if (!hasValidDays) break
weekOffset++
}
times.sort((a, b) => a.getTime() - b.getTime())
}
else if (data.frequency === 'monthly') {
const getSelectedDays = (): (number | 'last')[] => {
if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0)
return data.visual_config.monthly_days
return [1]
}
const selectedDays = [...new Set(getSelectedDays())]
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
// Get current time completely in user timezone
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
let executionCount = 0
let monthOffset = 0
while (executionCount < count) {
const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
const monthlyExecutions: Date[] = []
const processedDays = new Set<number>()
for (const selectedDay of selectedDays) {
let targetDay: number
if (selectedDay === 'last') {
targetDay = daysInMonth
}
else {
const dayNumber = selectedDay as number
if (dayNumber > daysInMonth)
continue
targetDay = dayNumber
}
if (processedDays.has(targetDay))
continue
processedDays.add(targetDay)
const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
// Only add if execution time is in the future
if (execution > userCurrentTime)
monthlyExecutions.push(execution)
}
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
for (const execution of monthlyExecutions) {
if (executionCount >= count) break
times.push(execution)
executionCount++
}
monthOffset++
}
}
else {
for (let i = 0; i < count; i++) {
const execution = new Date(userToday)
execution.setDate(userToday.getDate() + i)
times.push(execution)
}
}
return times
}
export const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
const resolvedTimezone = resolveTimezone(timezone)
return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone)
}
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
const timezone = resolveTimezone(data.timezone)
const times = getNextExecutionTimes(data, count)
return times.map((date) => {
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
return formatExecutionTime(date, timezone, includeWeekday, true) // Panel shows timezone
})
}
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
const timezone = resolveTimezone(data.timezone)
// Return placeholder for cron mode with empty or invalid expression
if (data.mode === 'cron') {
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
return '--'
}
// Get Date objects (not formatted strings)
const times = getNextExecutionTimes(data, 1)
if (times.length === 0) {
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0)
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone
}
// Format the first execution time without timezone for node display
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
return formatExecutionTime(times[0], timezone, includeWeekday, false) // Node doesn't show timezone
}

View File

@@ -0,0 +1,350 @@
import { isValidCronExpression, parseCronExpression } from './cron-parser'
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
import type { ScheduleTriggerNodeType } from '../types'
import { BlockEnum } from '../../../types'
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
describe('cron-parser + execution-time-calculator integration', () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
})
afterAll(() => {
jest.useRealTimers()
})
const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
type: BlockEnum.TriggerSchedule,
title: 'test-schedule',
mode: 'cron',
frequency: 'daily',
timezone: 'UTC',
...overrides,
} as ScheduleTriggerNodeType)
describe('backward compatibility validation', () => {
it('maintains exact behavior for legacy cron expressions', () => {
const legacyExpressions = [
'15 10 1 * *', // Monthly 1st at 10:15
'0 0 * * 0', // Weekly Sunday midnight
'*/5 * * * *', // Every 5 minutes
'0 9-17 * * 1-5', // Business hours weekdays
'30 14 * * 1', // Monday 14:30
'0 0 1,15 * *', // 1st and 15th midnight
]
legacyExpressions.forEach((expression) => {
// Test direct cron-parser usage
const directResult = parseCronExpression(expression, 'UTC')
expect(directResult).toHaveLength(5)
expect(isValidCronExpression(expression)).toBe(true)
// Test through execution-time-calculator
const data = createCronData({ cron_expression: expression })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(calculatorResult).toHaveLength(5)
// Results should be identical
directResult.forEach((directDate, index) => {
const calcDate = calculatorResult[index]
expect(calcDate.getTime()).toBe(directDate.getTime())
expect(calcDate.getHours()).toBe(directDate.getHours())
expect(calcDate.getMinutes()).toBe(directDate.getMinutes())
})
})
})
it('validates timezone handling consistency', () => {
const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London']
const expression = '0 12 * * *' // Daily noon
timezones.forEach((timezone) => {
// Direct cron-parser call
const directResult = parseCronExpression(expression, timezone)
// Through execution-time-calculator
const data = createCronData({ cron_expression: expression, timezone })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(directResult).toHaveLength(5)
expect(calculatorResult).toHaveLength(5)
// All results should show noon (12:00) in their respective timezone
directResult.forEach(date => expect(date.getHours()).toBe(12))
calculatorResult.forEach(date => expect(date.getHours()).toBe(12))
// Cross-validation: results should be identical
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
})
it('error handling consistency', () => {
const invalidExpressions = [
'', // Empty string
' ', // Whitespace only
'60 10 1 * *', // Invalid minute
'15 25 1 * *', // Invalid hour
'15 10 32 * *', // Invalid day
'15 10 1 13 *', // Invalid month
'15 10 1', // Too few fields
'15 10 1 * * *', // Too many fields
'invalid expression', // Completely invalid
]
invalidExpressions.forEach((expression) => {
// Direct cron-parser calls
expect(isValidCronExpression(expression)).toBe(false)
expect(parseCronExpression(expression, 'UTC')).toEqual([])
// Through execution-time-calculator
const data = createCronData({ cron_expression: expression })
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
// getNextExecutionTime should return '--' for invalid cron
const timeString = getNextExecutionTime(data)
expect(timeString).toBe('--')
})
})
})
describe('enhanced features integration', () => {
it('month and day abbreviations work end-to-end', () => {
const enhancedExpressions = [
{ expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM
{ expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM
{ expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th
{ expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon
]
enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => {
// Validate through both paths
expect(isValidCronExpression(expr)).toBe(true)
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 3)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate expected properties
const validateDate = (date: Date) => {
expect(date.getHours()).toBe(hour)
expect(date.getMinutes()).toBe(minute)
if (month !== undefined) {
if (Array.isArray(month))
expect(month).toContain(date.getMonth())
else
expect(date.getMonth()).toBe(month)
}
if (day !== undefined)
expect(date.getDate()).toBe(day)
if (weekday !== undefined)
expect(date.getDay()).toBe(weekday)
}
directResult.forEach(validateDate)
calculatorResult.forEach(validateDate)
})
})
it('predefined expressions work through execution-time-calculator', () => {
const predefExpressions = [
{ expr: '@daily', hour: 0, minute: 0 },
{ expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday
{ expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month
{ expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st
]
predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => {
expect(isValidCronExpression(expr)).toBe(true)
const data = createCronData({ cron_expression: expr })
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
expect(date.getHours()).toBe(hour)
expect(date.getMinutes()).toBe(minute)
if (weekday !== undefined) expect(date.getDay()).toBe(weekday)
if (day !== undefined) expect(date.getDate()).toBe(day)
if (month !== undefined) expect(date.getMonth()).toBe(month)
})
})
})
it('special characters integration', () => {
const specialExpressions = [
'0 9 ? * 1', // ? wildcard for day
'0 12 * * 7', // Sunday as 7
'0 15 L * *', // Last day of month
]
specialExpressions.forEach((expr) => {
// Should validate and parse successfully
expect(isValidCronExpression(expr)).toBe(true)
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 2)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Results should be consistent
expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours())
expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes())
})
})
})
describe('DST and timezone edge cases', () => {
it('handles DST transitions consistently', () => {
// Test around DST spring forward (March 2024)
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
const expression = '0 2 * * *' // 2 AM daily (problematic during DST)
const timezone = 'America/New_York'
const directResult = parseCronExpression(expression, timezone)
const data = createCronData({ cron_expression: expression, timezone })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Both should handle DST gracefully
// During DST spring forward, 2 AM becomes 3 AM - this is correct behavior
directResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
// Results should be identical
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
it('complex timezone scenarios', () => {
const scenarios = [
{ tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30
{ tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30
{ tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14
]
scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => {
const directResult = parseCronExpression(expr, tz)
const data = createCronData({ cron_expression: expr, timezone: tz })
const calculatorResult = getNextExecutionTimes(data, 2)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate expected time
directResult.forEach((date) => {
expect(date.getHours()).toBe(expectedHour)
expect(date.getMinutes()).toBe(expectedMinute)
})
calculatorResult.forEach((date) => {
expect(date.getHours()).toBe(expectedHour)
expect(date.getMinutes()).toBe(expectedMinute)
})
// Cross-validate consistency
expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime())
})
})
})
describe('performance and reliability', () => {
it('handles high-frequency expressions efficiently', () => {
const highFreqExpressions = [
'*/1 * * * *', // Every minute
'*/5 * * * *', // Every 5 minutes
'0,15,30,45 * * * *', // Every 15 minutes
]
highFreqExpressions.forEach((expr) => {
const start = performance.now()
// Test both direct and through calculator
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 5)
const end = performance.now()
expect(directResult).toHaveLength(5)
expect(calculatorResult).toHaveLength(5)
expect(end - start).toBeLessThan(100) // Should be fast
// Results should be consistent
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
})
it('stress test with complex expressions', () => {
const complexExpressions = [
'15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays
'0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours
'30 9 L * *', // Last day of month, 9:30 AM
]
complexExpressions.forEach((expr) => {
if (isValidCronExpression(expr)) {
const directResult = parseCronExpression(expr, 'America/New_York')
const data = createCronData({
cron_expression: expr,
timezone: 'America/New_York',
})
const calculatorResult = getNextExecutionTimes(data, 3)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate consistency where results exist
const minLength = Math.min(directResult.length, calculatorResult.length)
for (let i = 0; i < minLength; i++)
expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime())
}
})
})
})
describe('format compatibility', () => {
it('getNextExecutionTime formatting consistency', () => {
const testCases = [
{ expr: '0 9 * * *', timezone: 'UTC' },
{ expr: '30 14 * * 1-5', timezone: 'America/New_York' },
{ expr: '@daily', timezone: 'Asia/Tokyo' },
]
testCases.forEach(({ expr, timezone }) => {
const data = createCronData({ cron_expression: expr, timezone })
const timeString = getNextExecutionTime(data)
// Should return a formatted time string, not '--'
expect(timeString).not.toBe('--')
expect(typeof timeString).toBe('string')
expect(timeString.length).toBeGreaterThan(0)
// Should contain expected format elements
expect(timeString).toMatch(/\d+:\d+/) // Time format
expect(timeString).toMatch(/AM|PM/) // 12-hour format
expect(timeString).toMatch(/\d{4}/) // Year
})
})
})
})