Skip to content

Commit 99b5283

Browse files
authored
Merge pull request #26 from Multiplier-Labs/fix/workflow-time-selector-and-biweekly-toggle
Improve workflow time selector and biweekly day toggle
2 parents 07546eb + 415cc20 commit 99b5283

File tree

6 files changed

+140
-44
lines changed

6 files changed

+140
-44
lines changed

dist/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
77
<title>Codekin</title>
8-
<script type="module" crossorigin src="/assets/index-1ZlQpjMW.js"></script>
9-
<link rel="stylesheet" crossorigin href="/assets/index-_zZz8afN.css">
8+
<script type="module" crossorigin src="/assets/index-C0uevU2N.js"></script>
9+
<link rel="stylesheet" crossorigin href="/assets/index-Cj7k_spD.css">
1010
</head>
1111
<body class="bg-neutral-12 text-neutral-2">
1212
<div id="root"></div>

src/components/AddWorkflowModal.tsx

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { ReviewRepoConfig, WorkflowKindInfo } from '../lib/workflowApi'
1515
import {
1616
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
1717
buildCron, describeCron, slugify, kindCategory,
18-
toTimeValue, fromTimeValue,
18+
toTimeValue, fromTimeValue, isBiweeklyDow,
1919
} from '../lib/workflowHelpers'
2020
import { CategoryBadge } from './WorkflowBadges'
2121
import { RepoList } from './RepoList'
@@ -275,34 +275,69 @@ function StepSchedule({
275275
step={900}
276276
value={toTimeValue(form.cronHour, form.cronMinute)}
277277
onChange={handleTimeChange}
278-
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-full"
278+
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-40 themed-time-input"
279279
/>
280280
</div>
281281

282282
<div>
283283
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
284284
<div className="flex flex-wrap gap-1.5 mb-2">
285-
{DAY_PRESETS.map(p => (
286-
<FrequencyButton
287-
key={p.dow}
288-
label={p.label}
289-
dow={p.dow}
290-
selected={form.cronDow === p.dow}
291-
onSelect={dow => onChange({ cronDow: dow })}
292-
/>
293-
))}
285+
{DAY_PRESETS.map(p => {
286+
const selected = form.cronDow === p.dow
287+
return (
288+
<FrequencyButton
289+
key={p.dow}
290+
label={p.label}
291+
dow={p.dow}
292+
selected={selected}
293+
onSelect={dow => onChange({ cronDow: dow })}
294+
/>
295+
)
296+
})}
294297
</div>
295298
<div className="flex gap-1.5">
296-
{DAY_INDIVIDUAL.map(p => (
297-
<FrequencyButton
298-
key={p.dow}
299-
label={p.label}
300-
dow={p.dow}
301-
selected={form.cronDow === p.dow}
302-
onSelect={dow => onChange({ cronDow: dow })}
303-
/>
304-
))}
299+
{DAY_INDIVIDUAL.map(p => {
300+
const baseDow = isBiweeklyDow(form.cronDow)
301+
? form.cronDow.split('-').slice(1).join('-')
302+
: form.cronDow
303+
const selected = baseDow === p.dow
304+
return (
305+
<FrequencyButton
306+
key={p.dow}
307+
label={p.label}
308+
dow={p.dow}
309+
selected={selected}
310+
onSelect={dow => {
311+
const biweekly = isBiweeklyDow(form.cronDow)
312+
onChange({ cronDow: biweekly ? `biweekly-${dow}` : dow })
313+
}}
314+
/>
315+
)
316+
})}
305317
</div>
318+
{/* Weekly / Bi-weekly toggle — visible when a single day is selected */}
319+
{(() => {
320+
const isDay = DAY_INDIVIDUAL.some(d => d.dow === form.cronDow || form.cronDow === `biweekly-${d.dow}`)
321+
if (!isDay) return null
322+
const biweekly = isBiweeklyDow(form.cronDow)
323+
const baseDow = biweekly ? form.cronDow.split('-').slice(1).join('-') : form.cronDow
324+
return (
325+
<div className="flex gap-1.5 mt-2">
326+
<FrequencyButton
327+
label="Every week"
328+
dow={baseDow}
329+
selected={!biweekly}
330+
onSelect={dow => onChange({ cronDow: dow })}
331+
/>
332+
<FrequencyButton
333+
label="Every 2 weeks"
334+
dow={`biweekly-${baseDow}`}
335+
selected={biweekly}
336+
onSelect={dow => onChange({ cronDow: dow })}
337+
/>
338+
</div>
339+
)
340+
})()}
306341
<div className="mt-2 text-[13px] text-neutral-5">
307342
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
308343
</div>

src/components/EditWorkflowModal.tsx

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ import { useState } from 'react'
77
import { IconX, IconLoader2 } from '@tabler/icons-react'
88
import type { ReviewRepoConfig } from '../lib/workflowApi'
99
import {
10-
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
10+
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL, isBiweeklyDow,
1111
buildCron, parseCron, describeCron, kindLabel,
1212
toTimeValue, fromTimeValue,
1313
} from '../lib/workflowHelpers'
1414
import { CategoryBadge } from './WorkflowBadges'
1515

16+
const btnClass = (selected: boolean) =>
17+
`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
18+
selected
19+
? 'border-accent-6 bg-accent-9/40 text-accent-2'
20+
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
21+
}`
22+
1623
interface Props {
1724
repo: ReviewRepoConfig
1825
schedules?: unknown[]
@@ -52,6 +59,9 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
5259
}
5360

5461
const repoShortName = repo.repoPath.split('/').pop() || repo.name
62+
const biweekly = isBiweeklyDow(form.cronDow)
63+
const baseDow = biweekly ? form.cronDow.split('-').slice(1).join('-') : form.cronDow
64+
const isDay = DAY_INDIVIDUAL.some(d => d.dow === baseDow)
5565

5666
return (
5767
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
@@ -114,7 +124,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
114124
const { hour, minute } = fromTimeValue(e.target.value)
115125
setForm(f => ({ ...f, cronHour: hour, cronMinute: minute }))
116126
}}
117-
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-full mb-3"
127+
className="rounded-md border border-neutral-7 bg-neutral-10 px-3 py-2 text-[15px] text-neutral-1 focus:border-accent-6 focus:outline-none w-40 mb-3 themed-time-input"
118128
/>
119129
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
120130
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -123,11 +133,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
123133
key={p.dow}
124134
type="button"
125135
onClick={() => setForm(f => ({ ...f, cronDow: p.dow }))}
126-
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
127-
form.cronDow === p.dow
128-
? 'border-accent-6 bg-accent-9/40 text-accent-2'
129-
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
130-
}`}
136+
className={btnClass(form.cronDow === p.dow)}
131137
>
132138
{p.label}
133139
</button>
@@ -138,17 +144,23 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
138144
<button
139145
key={p.dow}
140146
type="button"
141-
onClick={() => setForm(f => ({ ...f, cronDow: p.dow }))}
142-
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
143-
form.cronDow === p.dow
144-
? 'border-accent-6 bg-accent-9/40 text-accent-2'
145-
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
146-
}`}
147+
onClick={() => setForm(f => ({ ...f, cronDow: biweekly ? `biweekly-${p.dow}` : p.dow }))}
148+
className={btnClass(baseDow === p.dow)}
147149
>
148150
{p.label}
149151
</button>
150152
))}
151153
</div>
154+
{isDay && (
155+
<div className="flex gap-1.5 mt-2">
156+
<button type="button" onClick={() => setForm(f => ({ ...f, cronDow: baseDow }))} className={btnClass(!biweekly)}>
157+
Every week
158+
</button>
159+
<button type="button" onClick={() => setForm(f => ({ ...f, cronDow: `biweekly-${baseDow}` }))} className={btnClass(biweekly)}>
160+
Every 2 weeks
161+
</button>
162+
</div>
163+
)}
152164
<div className="mt-2 text-[13px] text-neutral-5">
153165
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
154166
</div>

src/index.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,21 @@ html, body, #root {
653653
padding: 0.25em 0.75em;
654654
border: 1px solid var(--color-neutral-9);
655655
}
656+
657+
/* Themed time input — dark/light color-scheme + icon contrast */
658+
[data-theme="dark"] .themed-time-input {
659+
color-scheme: dark;
660+
}
661+
662+
[data-theme="light"] .themed-time-input {
663+
color-scheme: light;
664+
}
665+
666+
.themed-time-input::-webkit-calendar-picker-indicator {
667+
filter: var(--time-icon-filter, none);
668+
cursor: pointer;
669+
}
670+
671+
[data-theme="dark"] .themed-time-input::-webkit-calendar-picker-indicator {
672+
filter: invert(0.8) sepia(0.1) saturate(0.5) hue-rotate(160deg);
673+
}

src/lib/workflowHelpers.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ describe('workflowHelpers', () => {
2929
expect(buildCron(14, '1-5', 15)).toBe('15 14 * * 1-5')
3030
})
3131

32-
it('builds a biweekly cron expression', () => {
32+
it('builds a biweekly cron expression (legacy)', () => {
3333
expect(buildCron(6, 'biweekly')).toBe('0 6 */14 * *')
3434
expect(buildCron(9, 'biweekly', 45)).toBe('45 9 */14 * *')
3535
})
36+
37+
it('builds a biweekly cron expression with specific day', () => {
38+
expect(buildCron(6, 'biweekly-1')).toBe('0 6 */14 * 1')
39+
expect(buildCron(9, 'biweekly-5', 30)).toBe('30 9 */14 * 5')
40+
})
3641
})
3742

3843
describe('parseCron', () => {
@@ -46,11 +51,16 @@ describe('workflowHelpers', () => {
4651
expect(parseCron('15 14 * * 1-5')).toEqual({ hour: 14, minute: 15, dow: '1-5' })
4752
})
4853

49-
it('parses biweekly expression', () => {
54+
it('parses biweekly expression (legacy, no day)', () => {
5055
expect(parseCron('0 6 */14 * *')).toEqual({ hour: 6, minute: 0, dow: 'biweekly' })
5156
expect(parseCron('45 9 */14 * *')).toEqual({ hour: 9, minute: 45, dow: 'biweekly' })
5257
})
5358

59+
it('parses biweekly expression with specific day', () => {
60+
expect(parseCron('0 6 */14 * 1')).toEqual({ hour: 6, minute: 0, dow: 'biweekly-1' })
61+
expect(parseCron('30 9 */14 * 5')).toEqual({ hour: 9, minute: 30, dow: 'biweekly-5' })
62+
})
63+
5464
it('returns defaults for invalid expression', () => {
5565
expect(parseCron('invalid')).toEqual({ hour: 6, minute: 0, dow: '*' })
5666
expect(parseCron('* *')).toEqual({ hour: 6, minute: 0, dow: '*' })
@@ -110,11 +120,16 @@ describe('workflowHelpers', () => {
110120
expect(describeCron('0 9 * * 0')).toBe('Weekly Sun at 09:00')
111121
})
112122

113-
it('describes biweekly cron', () => {
123+
it('describes biweekly cron (legacy)', () => {
114124
expect(describeCron('0 6 */14 * *')).toBe('Bi-weekly at 06:00')
115125
expect(describeCron('30 9 */14 * *')).toBe('Bi-weekly at 09:30')
116126
})
117127

128+
it('describes biweekly cron with specific day', () => {
129+
expect(describeCron('0 6 */14 * 1')).toBe('Bi-weekly Mon at 06:00')
130+
expect(describeCron('30 9 */14 * 5')).toBe('Bi-weekly Fri at 09:30')
131+
})
132+
118133
it('returns raw expression for invalid format', () => {
119134
expect(describeCron('invalid')).toBe('invalid')
120135
})

src/lib/workflowHelpers.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export const WORKFLOW_KINDS = [
1818
export const DAY_PRESETS = [
1919
{ label: 'Daily', dow: '*' },
2020
{ label: 'Weekdays', dow: '1-5' },
21-
{ label: 'Bi-weekly', dow: 'biweekly' },
2221
]
2322

2423
export const DAY_INDIVIDUAL = [
@@ -33,21 +32,35 @@ export const DAY_INDIVIDUAL = [
3332

3433
export const DAY_PATTERNS = [...DAY_PRESETS, ...DAY_INDIVIDUAL]
3534

35+
/** Check if a dow value represents a bi-weekly schedule (e.g. `"biweekly"` or `"biweekly-1"`). */
36+
export function isBiweeklyDow(dow: string): boolean {
37+
return dow.startsWith('biweekly')
38+
}
39+
40+
/** Extract the day-of-week from a biweekly dow value, e.g. `"biweekly-1"` → `"1"`. Returns `"*"` for legacy `"biweekly"`. */
41+
export function biweeklyDay(dow: string): string {
42+
const parts = dow.split('-')
43+
return parts.length >= 2 ? parts.slice(1).join('-') : '*'
44+
}
45+
3646
/** Build a cron expression from hour (0–23), day-of-week pattern, and optional minute (0–59). */
3747
export function buildCron(hour: number, dow: string, minute = 0): string {
38-
if (dow === 'biweekly') return `${minute} ${hour} */14 * *`
48+
if (isBiweeklyDow(dow)) return `${minute} ${hour} */14 * ${biweeklyDay(dow)}`
3949
return `${minute} ${hour} * * ${dow}`
4050
}
4151

4252
/** Parse a 5-field cron expression into hour, minute, and day-of-week components. Falls back to 6:00 AM daily. */
4353
export function parseCron(expr: string): { hour: number; minute: number; dow: string } {
4454
const parts = expr.trim().split(/\s+/)
4555
if (parts.length === 5) {
46-
const isBiweekly = parts[2] === '*/14'
56+
const biweekly = parts[2] === '*/14'
57+
const dow = biweekly
58+
? (parts[4] === '*' ? 'biweekly' : `biweekly-${parts[4]}`)
59+
: parts[4]
4760
return {
4861
hour: parseInt(parts[1]) || 0,
4962
minute: parseInt(parts[0]) || 0,
50-
dow: isBiweekly ? 'biweekly' : parts[4],
63+
dow,
5164
}
5265
}
5366
return { hour: 6, minute: 0, dow: '*' }
@@ -79,10 +92,13 @@ export function describeCron(expr: string): string {
7992
const [min, hour, dom, , dow] = parts
8093
const pad = (n: string) => n.length === 1 ? `0${n}` : n
8194
const time = `${pad(hour)}:${pad(min)}`
82-
if (dom === '*/14') return `Bi-weekly at ${time}`
95+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
96+
if (dom === '*/14') {
97+
const dayName = dow !== '*' ? ` ${days[parseInt(dow)] ?? dow}` : ''
98+
return `Bi-weekly${dayName} at ${time}`
99+
}
83100
if (dow === '*') return `Daily at ${time}`
84101
if (dow === '1-5') return `Weekdays at ${time}`
85-
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
86102
const dayName = days[parseInt(dow)] ?? dow
87103
return `Weekly ${dayName} at ${time}`
88104
}

0 commit comments

Comments
 (0)