Skip to content

Commit 41a797d

Browse files
xrutayisireclaude
andauthored
fix: respect defaultPeriod and allowedPeriods in period detection (#87)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df4965a commit 41a797d

File tree

5 files changed

+142
-22
lines changed

5 files changed

+142
-22
lines changed

.claude/skills/visual-test/SKILL.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@ Compare the local Storybook running on this branch against the production deploy
1515

1616
## Setup
1717

18-
1. Check if local Storybook is already running on port 9009. If not, start it:
18+
1. Create (or empty) the screenshots directory at the repository root:
19+
```bash
20+
rm -rf <project-root>/screenshots && mkdir -p <project-root>/screenshots
21+
```
22+
All screenshots taken during this test must be saved inside `<project-root>/screenshots/`.
23+
2. Check if local Storybook is already running on port 9009. If not, start it:
1924
```bash
2025
cd <project-root> && yarn storybook &
2126
```
22-
2. Wait for `http://localhost:9009` to respond before proceeding.
27+
3. Wait for `http://localhost:9009` to respond before proceeding.
2328

2429
## Test procedure
2530

@@ -28,10 +33,10 @@ For **every story** listed below, perform the full comparison cycle:
2833
### Comparison cycle (repeat for each story)
2934

3035
1. **Navigate to the production story** at the given URL.
31-
2. **Take a full-page screenshot** and save it as `prod-<story-slug>.png`.
36+
2. **Take a full-page screenshot** and save it as `screenshots/prod-<story-slug>.png`.
3237
3. **Take an accessibility snapshot** of the production page.
3338
4. **Navigate to the same story locally** at `http://localhost:9009`.
34-
5. **Take a full-page screenshot** and save it as `local-<story-slug>.png`.
39+
5. **Take a full-page screenshot** and save it as `screenshots/local-<story-slug>.png`.
3540
6. **Take an accessibility snapshot** of the local page.
3641
7. **Compare both screenshots visually.** Report any difference in:
3742
- Text content (every label, placeholder, value, heading, and button text must be identical)
@@ -85,7 +90,7 @@ Test each of the following stories by navigating to the corresponding Storybook
8590

8691
## Interaction tests
8792

88-
After the static visual comparison of all stories, perform the following interaction tests on the **Dynamic Settings** story. For each interaction, perform the action on both production and local, then compare screenshots and snapshots.
93+
After the static visual comparison of all stories, perform the following interaction tests on the **Dynamic Settings** story. For each interaction, perform the action on both production and local, then compare screenshots and snapshots. Save all interaction screenshots in the `screenshots/` folder using the naming pattern `screenshots/prod-<interaction-slug>.png` and `screenshots/local-<interaction-slug>.png`.
8994

9095
### Period dropdown
9196

@@ -193,3 +198,11 @@ After all checks are complete, provide a summary:
193198
- Pass/fail verdict
194199

195200
If all checks pass, confirm: **"No visual, textual, or interaction regressions detected. The branch is safe to merge."**
201+
202+
## Cleanup
203+
204+
After the final report is delivered, delete all contents of the screenshots folder:
205+
```bash
206+
rm -rf <project-root>/screenshots
207+
```
208+
This ensures no screenshot artifacts remain in the repository after the test run.

src/Cron.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function Cron(props: CronProps) {
2323
displayError = true,
2424
onError,
2525
className,
26-
defaultPeriod = 'day',
26+
defaultPeriod,
2727
allowEmpty = 'for-default-value',
2828
humanizeLabels = true,
2929
humanizeValue = false,
@@ -64,7 +64,8 @@ export default function Cron(props: CronProps) {
6464
getPopupContainer,
6565
} = props
6666
const internalValueRef = useRef<string>(value)
67-
const defaultPeriodRef = useRef<PeriodType>(defaultPeriod)
67+
const effectiveDefaultPeriod = defaultPeriod ?? 'day'
68+
const defaultPeriodRef = useRef<PeriodType>(effectiveDefaultPeriod)
6869
const [period, setPeriod] = useState<PeriodType | undefined>()
6970
const [monthDays, setMonthDays] = useState<number[] | undefined>()
7071
const [months, setMonths] = useState<number[] | undefined>()
@@ -94,6 +95,8 @@ export default function Cron(props: CronProps) {
9495
setMonths,
9596
setWeekDays,
9697
setPeriod,
98+
defaultPeriod,
99+
allowedPeriods,
97100
)
98101
},
99102
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -118,6 +121,8 @@ export default function Cron(props: CronProps) {
118121
setMonths,
119122
setWeekDays,
120123
setPeriod,
124+
defaultPeriod,
125+
allowedPeriods,
121126
)
122127
}
123128
},

src/converter.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export function setValuesFromCronString(
3636
setMonths: SetValueNumbersOrUndefined,
3737
setWeekDays: SetValueNumbersOrUndefined,
3838
setPeriod: SetValuePeriod,
39+
defaultPeriod?: PeriodType,
40+
allowedPeriods?: PeriodType[],
3941
) {
4042
if (onError) {
4143
onError(undefined)
@@ -80,7 +82,11 @@ export function setValuesFromCronString(
8082

8183
try {
8284
const cronParts = parseCronString(cronString)
83-
const period = getPeriodFromCronParts(cronParts)
85+
const period = getPeriodFromCronParts(
86+
cronParts,
87+
defaultPeriod,
88+
allowedPeriods,
89+
)
8490

8591
setPeriod(period)
8692
setMinutes(cronParts[0])
@@ -285,21 +291,75 @@ function cronToString(parts: string[]) {
285291
}
286292

287293
/**
288-
* Find the period from cron parts
294+
* Period hierarchy from most specific to most broad.
295+
* A cron expression with minimum period P is compatible with any period >= P.
296+
*/
297+
const PERIOD_ORDER: PeriodType[] = [
298+
'minute',
299+
'hour',
300+
'day',
301+
'week',
302+
'month',
303+
'year',
304+
]
305+
306+
function getPeriodRank(period: PeriodType): number {
307+
const rank = PERIOD_ORDER.indexOf(period)
308+
return rank === -1 ? -1 : rank
309+
}
310+
311+
/**
312+
* Find the period from cron parts, respecting defaultPeriod and allowedPeriods
313+
* when the cron expression is ambiguous (compatible with multiple periods).
289314
*/
290-
function getPeriodFromCronParts(cronParts: number[][]): PeriodType {
315+
function getPeriodFromCronParts(
316+
cronParts: number[][],
317+
defaultPeriod?: PeriodType,
318+
allowedPeriods?: PeriodType[],
319+
): PeriodType {
320+
// Determine the minimum compatible period from the cron fields
321+
let minPeriod: PeriodType
291322
if (cronParts[3].length > 0) {
292-
return 'year'
323+
minPeriod = 'year'
293324
} else if (cronParts[2].length > 0) {
294-
return 'month'
325+
minPeriod = 'month'
295326
} else if (cronParts[4].length > 0) {
296-
return 'week'
327+
minPeriod = 'week'
297328
} else if (cronParts[1].length > 0) {
298-
return 'day'
329+
minPeriod = 'day'
299330
} else if (cronParts[0].length > 0) {
300-
return 'hour'
331+
minPeriod = 'hour'
332+
} else {
333+
minPeriod = 'minute'
301334
}
302-
return 'minute'
335+
336+
const minRank = getPeriodRank(minPeriod)
337+
338+
// If defaultPeriod is explicitly provided and compatible, prefer it
339+
if (defaultPeriod) {
340+
const defaultRank = getPeriodRank(defaultPeriod)
341+
342+
if (
343+
defaultRank >= minRank &&
344+
(!allowedPeriods || allowedPeriods.includes(defaultPeriod))
345+
) {
346+
return defaultPeriod
347+
}
348+
}
349+
350+
// Find the smallest allowed period that is compatible (rank >= minRank)
351+
if (allowedPeriods) {
352+
for (let i = minRank; i < PERIOD_ORDER.length; i++) {
353+
if (allowedPeriods.includes(PERIOD_ORDER[i])) {
354+
return PERIOD_ORDER[i]
355+
}
356+
}
357+
} else {
358+
return minPeriod
359+
}
360+
361+
// Fallback: use minPeriod even if not allowed (edge case)
362+
return minPeriod
303363
}
304364

305365
/**

src/tests/Cron.defaultValue.test.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('Cron defaultValue test suite', () => {
3131
clockFormat?: ClockFormat
3232
allowedDropdowns?: CronType[]
3333
defaultPeriod?: PeriodType
34+
allowedPeriods?: PeriodType[]
3435
periodSelect: PeriodType | undefined
3536
monthsSelect: string | undefined
3637
monthDaysSelect: string | undefined
@@ -248,15 +249,52 @@ describe('Cron defaultValue test suite', () => {
248249
minutesSelect: 'every minute',
249250
},
250251
{
251-
title: 'that default period is ignored when default value is not empty',
252+
title:
253+
'that default period is respected when value is compatible with it',
252254
defaultValue: '* * * * *',
253255
defaultPeriod: 'year',
254-
periodSelect: 'minute',
256+
periodSelect: 'year',
257+
monthsSelect: 'every month',
258+
monthDaysSelect: 'every day of the month',
259+
weekDaysSelect: 'every day of the week',
260+
hoursSelect: 'every hour',
261+
minutesSelect: 'every minute',
262+
},
263+
{
264+
title:
265+
'that default period week is respected for ambiguous daily cron expression',
266+
defaultValue: '10 10 * * *',
267+
defaultPeriod: 'week',
268+
periodSelect: 'week',
255269
monthsSelect: undefined,
256270
monthDaysSelect: undefined,
257-
weekDaysSelect: undefined,
258-
hoursSelect: undefined,
259-
minutesSelect: undefined,
271+
weekDaysSelect: 'every day of the week',
272+
hoursSelect: '10',
273+
minutesSelect: '10',
274+
},
275+
{
276+
title:
277+
'that default period is ignored when value is not compatible with it',
278+
defaultValue: '10 10 * * 1',
279+
defaultPeriod: 'day',
280+
periodSelect: 'week',
281+
monthsSelect: undefined,
282+
monthDaysSelect: undefined,
283+
weekDaysSelect: 'MON',
284+
hoursSelect: '10',
285+
minutesSelect: '10',
286+
},
287+
{
288+
title:
289+
'that allowedPeriods is respected when detected period is not allowed',
290+
defaultValue: '10 10 * * *',
291+
allowedPeriods: ['week', 'month', 'year'],
292+
periodSelect: 'week',
293+
monthsSelect: undefined,
294+
monthDaysSelect: undefined,
295+
weekDaysSelect: 'every day of the week',
296+
hoursSelect: '10',
297+
minutesSelect: '10',
260298
},
261299
{
262300
title:
@@ -746,6 +784,7 @@ describe('Cron defaultValue test suite', () => {
746784
clockFormat,
747785
allowedDropdowns,
748786
defaultPeriod,
787+
allowedPeriods,
749788
periodSelect,
750789
monthsSelect,
751790
monthDaysSelect,
@@ -771,6 +810,7 @@ describe('Cron defaultValue test suite', () => {
771810
clockFormat={clockFormat}
772811
allowedDropdowns={allowedDropdowns}
773812
defaultPeriod={defaultPeriod}
813+
allowedPeriods={allowedPeriods}
774814
dropdownsConfig={dropdownsConfig}
775815
/>,
776816
)

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ export interface CronProps {
4848
leadingZero?: LeadingZero
4949

5050
/**
51-
* Define the default period when the default value is empty.
51+
* Define the default period when the value is empty.
52+
* When set and the cron value is ambiguous (compatible with multiple periods),
53+
* this period will be preferred over the auto-detected one.
5254
*
5355
* Default: 'day'
5456
*/

0 commit comments

Comments
 (0)