Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Codekin</title>
<script type="module" crossorigin src="/assets/index-1ZlQpjMW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_zZz8afN.css">
<script type="module" crossorigin src="/assets/index-C0uevU2N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cj7k_spD.css">
</head>
<body class="bg-neutral-12 text-neutral-2">
<div id="root"></div>
Expand Down
75 changes: 55 additions & 20 deletions src/components/AddWorkflowModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { ReviewRepoConfig, WorkflowKindInfo } from '../lib/workflowApi'
import {
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
buildCron, describeCron, slugify, kindCategory,
toTimeValue, fromTimeValue,
toTimeValue, fromTimeValue, isBiweeklyDow,
} from '../lib/workflowHelpers'
import { CategoryBadge } from './WorkflowBadges'
import { RepoList } from './RepoList'
Expand Down Expand Up @@ -275,34 +275,69 @@ function StepSchedule({
step={900}
value={toTimeValue(form.cronHour, form.cronMinute)}
onChange={handleTimeChange}
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"
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"
/>
</div>

<div>
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{DAY_PRESETS.map(p => (
<FrequencyButton
key={p.dow}
label={p.label}
dow={p.dow}
selected={form.cronDow === p.dow}
onSelect={dow => onChange({ cronDow: dow })}
/>
))}
{DAY_PRESETS.map(p => {
const selected = form.cronDow === p.dow
return (
<FrequencyButton
key={p.dow}
label={p.label}
dow={p.dow}
selected={selected}
onSelect={dow => onChange({ cronDow: dow })}
/>
)
})}
</div>
<div className="flex gap-1.5">
{DAY_INDIVIDUAL.map(p => (
<FrequencyButton
key={p.dow}
label={p.label}
dow={p.dow}
selected={form.cronDow === p.dow}
onSelect={dow => onChange({ cronDow: dow })}
/>
))}
{DAY_INDIVIDUAL.map(p => {
const baseDow = isBiweeklyDow(form.cronDow)
? form.cronDow.split('-').slice(1).join('-')
: form.cronDow
const selected = baseDow === p.dow
return (
<FrequencyButton
key={p.dow}
label={p.label}
dow={p.dow}
selected={selected}
onSelect={dow => {
const biweekly = isBiweeklyDow(form.cronDow)
onChange({ cronDow: biweekly ? `biweekly-${dow}` : dow })
}}
/>
)
})}
</div>
{/* Weekly / Bi-weekly toggle — visible when a single day is selected */}
{(() => {
const isDay = DAY_INDIVIDUAL.some(d => d.dow === form.cronDow || form.cronDow === `biweekly-${d.dow}`)
if (!isDay) return null
const biweekly = isBiweeklyDow(form.cronDow)
const baseDow = biweekly ? form.cronDow.split('-').slice(1).join('-') : form.cronDow
return (
<div className="flex gap-1.5 mt-2">
<FrequencyButton
label="Every week"
dow={baseDow}
selected={!biweekly}
onSelect={dow => onChange({ cronDow: dow })}
/>
<FrequencyButton
label="Every 2 weeks"
dow={`biweekly-${baseDow}`}
selected={biweekly}
onSelect={dow => onChange({ cronDow: dow })}
/>
</div>
)
})()}
<div className="mt-2 text-[13px] text-neutral-5">
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
</div>
Expand Down
38 changes: 25 additions & 13 deletions src/components/EditWorkflowModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import { useState } from 'react'
import { IconX, IconLoader2 } from '@tabler/icons-react'
import type { ReviewRepoConfig } from '../lib/workflowApi'
import {
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL,
WORKFLOW_KINDS, DAY_PRESETS, DAY_INDIVIDUAL, isBiweeklyDow,
buildCron, parseCron, describeCron, kindLabel,
toTimeValue, fromTimeValue,
} from '../lib/workflowHelpers'
import { CategoryBadge } from './WorkflowBadges'

const btnClass = (selected: boolean) =>
`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
selected
? 'border-accent-6 bg-accent-9/40 text-accent-2'
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
}`

interface Props {
repo: ReviewRepoConfig
schedules?: unknown[]
Expand Down Expand Up @@ -52,6 +59,9 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
}

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

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
Expand Down Expand Up @@ -114,7 +124,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
const { hour, minute } = fromTimeValue(e.target.value)
setForm(f => ({ ...f, cronHour: hour, cronMinute: minute }))
}}
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"
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"
/>
<label className="block text-[13px] font-medium text-neutral-3 mb-2">Frequency</label>
<div className="flex flex-wrap gap-1.5 mb-2">
Expand All @@ -123,11 +133,7 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
key={p.dow}
type="button"
onClick={() => setForm(f => ({ ...f, cronDow: p.dow }))}
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
form.cronDow === p.dow
? 'border-accent-6 bg-accent-9/40 text-accent-2'
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
}`}
className={btnClass(form.cronDow === p.dow)}
>
{p.label}
</button>
Expand All @@ -138,17 +144,23 @@ export function EditWorkflowModal({ repo, onClose, onSave }: Props) {
<button
key={p.dow}
type="button"
onClick={() => setForm(f => ({ ...f, cronDow: p.dow }))}
className={`rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors ${
form.cronDow === p.dow
? 'border-accent-6 bg-accent-9/40 text-accent-2'
: 'border-neutral-7 bg-neutral-10 text-neutral-3 hover:border-neutral-6 hover:text-neutral-2'
}`}
onClick={() => setForm(f => ({ ...f, cronDow: biweekly ? `biweekly-${p.dow}` : p.dow }))}
className={btnClass(baseDow === p.dow)}
>
{p.label}
</button>
))}
</div>
{isDay && (
<div className="flex gap-1.5 mt-2">
<button type="button" onClick={() => setForm(f => ({ ...f, cronDow: baseDow }))} className={btnClass(!biweekly)}>
Every week
</button>
<button type="button" onClick={() => setForm(f => ({ ...f, cronDow: `biweekly-${baseDow}` }))} className={btnClass(biweekly)}>
Every 2 weeks
</button>
</div>
)}
<div className="mt-2 text-[13px] text-neutral-5">
{describeCron(buildCron(form.cronHour, form.cronDow, form.cronMinute))}
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -653,3 +653,21 @@ html, body, #root {
padding: 0.25em 0.75em;
border: 1px solid var(--color-neutral-9);
}

/* Themed time input — dark/light color-scheme + icon contrast */
[data-theme="dark"] .themed-time-input {
color-scheme: dark;
}

[data-theme="light"] .themed-time-input {
color-scheme: light;
}

.themed-time-input::-webkit-calendar-picker-indicator {
filter: var(--time-icon-filter, none);
cursor: pointer;
}

[data-theme="dark"] .themed-time-input::-webkit-calendar-picker-indicator {
filter: invert(0.8) sepia(0.1) saturate(0.5) hue-rotate(160deg);
}
21 changes: 18 additions & 3 deletions src/lib/workflowHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ describe('workflowHelpers', () => {
expect(buildCron(14, '1-5', 15)).toBe('15 14 * * 1-5')
})

it('builds a biweekly cron expression', () => {
it('builds a biweekly cron expression (legacy)', () => {
expect(buildCron(6, 'biweekly')).toBe('0 6 */14 * *')
expect(buildCron(9, 'biweekly', 45)).toBe('45 9 */14 * *')
})

it('builds a biweekly cron expression with specific day', () => {
expect(buildCron(6, 'biweekly-1')).toBe('0 6 */14 * 1')
expect(buildCron(9, 'biweekly-5', 30)).toBe('30 9 */14 * 5')
})
})

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

it('parses biweekly expression', () => {
it('parses biweekly expression (legacy, no day)', () => {
expect(parseCron('0 6 */14 * *')).toEqual({ hour: 6, minute: 0, dow: 'biweekly' })
expect(parseCron('45 9 */14 * *')).toEqual({ hour: 9, minute: 45, dow: 'biweekly' })
})

it('parses biweekly expression with specific day', () => {
expect(parseCron('0 6 */14 * 1')).toEqual({ hour: 6, minute: 0, dow: 'biweekly-1' })
expect(parseCron('30 9 */14 * 5')).toEqual({ hour: 9, minute: 30, dow: 'biweekly-5' })
})

it('returns defaults for invalid expression', () => {
expect(parseCron('invalid')).toEqual({ hour: 6, minute: 0, dow: '*' })
expect(parseCron('* *')).toEqual({ hour: 6, minute: 0, dow: '*' })
Expand Down Expand Up @@ -110,11 +120,16 @@ describe('workflowHelpers', () => {
expect(describeCron('0 9 * * 0')).toBe('Weekly Sun at 09:00')
})

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

it('describes biweekly cron with specific day', () => {
expect(describeCron('0 6 */14 * 1')).toBe('Bi-weekly Mon at 06:00')
expect(describeCron('30 9 */14 * 5')).toBe('Bi-weekly Fri at 09:30')
})

it('returns raw expression for invalid format', () => {
expect(describeCron('invalid')).toBe('invalid')
})
Expand Down
28 changes: 22 additions & 6 deletions src/lib/workflowHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const WORKFLOW_KINDS = [
export const DAY_PRESETS = [
{ label: 'Daily', dow: '*' },
{ label: 'Weekdays', dow: '1-5' },
{ label: 'Bi-weekly', dow: 'biweekly' },
]

export const DAY_INDIVIDUAL = [
Expand All @@ -33,21 +32,35 @@ export const DAY_INDIVIDUAL = [

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

/** Check if a dow value represents a bi-weekly schedule (e.g. `"biweekly"` or `"biweekly-1"`). */
export function isBiweeklyDow(dow: string): boolean {
return dow.startsWith('biweekly')
}

/** Extract the day-of-week from a biweekly dow value, e.g. `"biweekly-1"` → `"1"`. Returns `"*"` for legacy `"biweekly"`. */
export function biweeklyDay(dow: string): string {
const parts = dow.split('-')
return parts.length >= 2 ? parts.slice(1).join('-') : '*'
}

/** Build a cron expression from hour (0–23), day-of-week pattern, and optional minute (0–59). */
export function buildCron(hour: number, dow: string, minute = 0): string {
if (dow === 'biweekly') return `${minute} ${hour} */14 * *`
if (isBiweeklyDow(dow)) return `${minute} ${hour} */14 * ${biweeklyDay(dow)}`
return `${minute} ${hour} * * ${dow}`
}

/** Parse a 5-field cron expression into hour, minute, and day-of-week components. Falls back to 6:00 AM daily. */
export function parseCron(expr: string): { hour: number; minute: number; dow: string } {
const parts = expr.trim().split(/\s+/)
if (parts.length === 5) {
const isBiweekly = parts[2] === '*/14'
const biweekly = parts[2] === '*/14'
const dow = biweekly
? (parts[4] === '*' ? 'biweekly' : `biweekly-${parts[4]}`)
: parts[4]
return {
hour: parseInt(parts[1]) || 0,
minute: parseInt(parts[0]) || 0,
dow: isBiweekly ? 'biweekly' : parts[4],
dow,
}
}
return { hour: 6, minute: 0, dow: '*' }
Expand Down Expand Up @@ -79,10 +92,13 @@ export function describeCron(expr: string): string {
const [min, hour, dom, , dow] = parts
const pad = (n: string) => n.length === 1 ? `0${n}` : n
const time = `${pad(hour)}:${pad(min)}`
if (dom === '*/14') return `Bi-weekly at ${time}`
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
if (dom === '*/14') {
const dayName = dow !== '*' ? ` ${days[parseInt(dow)] ?? dow}` : ''
return `Bi-weekly${dayName} at ${time}`
}
if (dow === '*') return `Daily at ${time}`
if (dow === '1-5') return `Weekdays at ${time}`
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const dayName = days[parseInt(dow)] ?? dow
return `Weekly ${dayName} at ${time}`
}
Expand Down