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
11 changes: 7 additions & 4 deletions src/commands/bulk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,13 @@ export async function bulkDueDate(
): Promise<BulkResult> {
const client = new ClickUpClient(config)
const timezone = await client.getUserTimezone()
const payload =
date === 'none' || date === 'clear'
? { due_date: null }
: { due_date: parseDueDate(date, timezone), due_date_time: false }
let payload: { due_date: number | null; due_date_time?: boolean }
if (date === 'none' || date === 'clear') {
payload = { due_date: null }
} else {
const parsed = parseDueDate(date, timezone)
payload = { due_date: parsed.ms, due_date_time: parsed.hasTime }
}
const outcomes = await runInBatches(taskIds, BULK_CONCURRENCY, id =>
client.updateTask(id, payload),
)
Expand Down
10 changes: 6 additions & 4 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ export async function createTask(
payload.priority = parsePriority(options.priority)
}
if (options.dueDate !== undefined) {
payload.due_date = parseDueDate(options.dueDate, timezone)
payload.due_date_time = false
const parsed = parseDueDate(options.dueDate, timezone)
payload.due_date = parsed.ms
payload.due_date_time = parsed.hasTime
}
if (options.startDate !== undefined) {
payload.start_date = parseDueDate(options.startDate, timezone)
payload.start_date_time = false
const parsed = parseDueDate(options.startDate, timezone)
payload.start_date = parsed.ms
payload.start_date_time = parsed.hasTime
}
if (options.assignee !== undefined) {
payload.assignees = [parseAssigneeId(options.assignee)]
Expand Down
2 changes: 1 addition & 1 deletion src/commands/goals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function createGoal(
return client.createGoal(config.teamId, name, {
description: opts?.description,
color: opts?.color,
...(opts?.dueDate ? { dueDate: parseDueDate(opts.dueDate, timezone) } : {}),
...(opts?.dueDate ? { dueDate: parseDueDate(opts.dueDate, timezone).ms } : {}),
})
}

Expand Down
100 changes: 79 additions & 21 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,89 @@ export function parsePriority(value: string): Priority {
throw new Error('Priority must be urgent, high, normal, low, or 1-4')
}

export function parseDueDate(value: string, timezone?: string): number {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('Date must be in YYYY-MM-DD format')
export interface ParsedDate {
ms: number
hasTime: boolean
}

const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/
const LOCAL_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/
const ISO_WITH_OFFSET_RE =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/

export function parseDueDate(value: string, timezone?: string): ParsedDate {
// Case 1: date-only (YYYY-MM-DD) — interpreted as midnight in the given timezone.
if (DATE_ONLY_RE.test(value)) {
const parts = value.split('-').map(Number)
const y = parts[0] as number
const m = parts[1] as number
const d = parts[2] as number
return { ms: wallClockToMs(y, m, d, 0, 0, 0, timezone, value), hasTime: false }
}
const parts = value.split('-').map(Number)
const y = parts[0] as number
const m = parts[1] as number
const d = parts[2] as number

// Case 2: date+time without offset (YYYY-MM-DDTHH:MM[:SS]) — interpreted in the given timezone.
const localMatch = LOCAL_DATETIME_RE.exec(value)
if (localMatch) {
const y = Number(localMatch[1])
const m = Number(localMatch[2])
const d = Number(localMatch[3])
const hh = Number(localMatch[4])
const mm = Number(localMatch[5])
const ss = localMatch[6] !== undefined ? Number(localMatch[6]) : 0
if (hh > 23 || mm > 59 || ss > 59) {
throw new Error(
'Date must be in YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 format (time component out of range)',
)
}
return { ms: wallClockToMs(y, m, d, hh, mm, ss, timezone, value), hasTime: true }
}

// Case 3: full ISO 8601 with explicit offset or Z — parsed as an absolute instant.
if (ISO_WITH_OFFSET_RE.test(value)) {
const ms = Date.parse(value)
if (!isNaN(ms)) return { ms, hasTime: true }
}

throw new Error(
'Date must be in YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 format (e.g. 2025-03-15T14:30 or 2025-03-15T14:30:00+08:00)',
)
}

function wallClockToMs(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
timezone: string | undefined,
rawValue: string,
): number {
if (timezone) {
try {
const ms = dateToTimezoneMs(y, m, d, timezone)
const ms = wallClockToTimezoneMs(year, month, day, hour, minute, second, timezone)
if (!isNaN(ms)) return ms
} catch {
// Invalid timezone string — fall through to UTC midnight
// Invalid timezone string — fall through to UTC.
}
}

const ms = Date.UTC(y, m - 1, d)
if (isNaN(ms)) throw new Error(`Invalid date: ${value}`)
const ms = Date.UTC(year, month - 1, day, hour, minute, second)
if (isNaN(ms)) throw new Error(`Invalid date: ${rawValue}`)
return ms
}

function dateToTimezoneMs(year: number, month: number, day: number, timezone: string): number {
// Find the UTC epoch that corresponds to midnight on YYYY-MM-DD in the given IANA timezone.
// Step 1: treat the date as UTC midnight (approximate starting point).
const approxUtc = new Date(Date.UTC(year, month - 1, day))
function wallClockToTimezoneMs(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
timezone: string,
): number {
// Find the UTC epoch that corresponds to a wall-clock time in the given IANA timezone.
// Step 1: treat the wall-clock components as if they were UTC (approximate starting point).
const approxUtc = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
// Step 2: express that UTC instant in the target timezone to find the TZ offset.
const tzStr = approxUtc.toLocaleString('en-US', {
timeZone: timezone,
Expand All @@ -60,7 +116,7 @@ function dateToTimezoneMs(year: number, month: number, day: number, timezone: st
const tzDate = new Date(tzStr + ' UTC')
// Step 4: offset = approxUtc - tzDate.
// e.g. for UTC-4: approxUtc=00:00Z, tzDate=20:00Z (prev day) → offset=+4h
// Midnight in the TZ = approxUtc + offset (shifts UTC midnight forward by the tz offset).
// Wall clock in the TZ = approxUtc + offset.
const offset = approxUtc.getTime() - tzDate.getTime()
return approxUtc.getTime() + offset
}
Expand Down Expand Up @@ -134,13 +190,15 @@ export function buildUpdatePayload(
if (opts.dueDate === 'none' || opts.dueDate === 'clear') {
payload.due_date = null
} else {
payload.due_date = parseDueDate(opts.dueDate, timezone)
payload.due_date_time = false
const parsed = parseDueDate(opts.dueDate, timezone)
payload.due_date = parsed.ms
payload.due_date_time = parsed.hasTime
}
}
if (opts.startDate !== undefined) {
payload.start_date = parseDueDate(opts.startDate, timezone)
payload.start_date_time = false
const parsed = parseDueDate(opts.startDate, timezone)
payload.start_date = parsed.ms
payload.start_date_time = parsed.hasTime
}
if (opts.assignee !== undefined || opts.removeAssignee !== undefined) {
payload.assignees = {}
Expand Down
20 changes: 16 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,14 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
'New status (fuzzy matched, e.g. "prog" matches "in progress")',
)
.option('--priority <level>', 'Priority: urgent, high, normal, low (or 1-4)')
.option('--due-date <date>', 'Due date (YYYY-MM-DD, or "none"/"clear" to remove)')
.option('--start-date <date>', 'Start date (YYYY-MM-DD)')
.option(
'--due-date <date>',
'Due date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset; or "none"/"clear" to remove)',
)
.option(
'--start-date <date>',
'Start date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)',
)
.option(
'--time-estimate <duration>',
'Time estimate (e.g. "2h", "30m", "1h30m", "0" or "none" to clear)',
Expand Down Expand Up @@ -480,8 +486,14 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
.option('-p, --parent <taskId>', 'Parent task ID (list auto-detected from parent)')
.option('-s, --status <status>', 'Initial status')
.option('--priority <level>', 'Priority: urgent, high, normal, low (or 1-4)')
.option('--due-date <date>', 'Due date (YYYY-MM-DD)')
.option('--start-date <date>', 'Start date (YYYY-MM-DD)')
.option(
'--due-date <date>',
'Due date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)',
)
.option(
'--start-date <date>',
'Start date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)',
)
.option('--assignee <userId>', 'Assignee user ID or "me"')
.option('--tags <tags>', 'Comma-separated tag names')
.option('--custom-item-id <id>', 'Custom task type ID (use to create initiatives)')
Expand Down
73 changes: 70 additions & 3 deletions tests/unit/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,56 @@ describe('parsePriority', () => {
})

describe('parseDueDate', () => {
it('parses YYYY-MM-DD format to UTC midnight', async () => {
it('parses YYYY-MM-DD format to UTC midnight with hasTime=false', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-03-15')
expect(result).toBe(Date.UTC(2025, 2, 15))
expect(result).toEqual({ ms: Date.UTC(2025, 2, 15), hasTime: false })
})

it('parses YYYY-MM-DD with timezone to midnight in that timezone', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-06-01', 'America/New_York')
// June 1 midnight ET = June 1 04:00 UTC (EDT = UTC-4)
expect(result).toBe(Date.UTC(2025, 5, 1, 4, 0, 0))
expect(result).toEqual({ ms: Date.UTC(2025, 5, 1, 4, 0, 0), hasTime: false })
})

it('parses YYYY-MM-DDTHH:MM as wall clock with hasTime=true (UTC fallback)', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-03-15T14:30')
expect(result).toEqual({ ms: Date.UTC(2025, 2, 15, 14, 30, 0), hasTime: true })
})

it('parses YYYY-MM-DDTHH:MM:SS as wall clock with hasTime=true', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-03-15T14:30:45')
expect(result).toEqual({ ms: Date.UTC(2025, 2, 15, 14, 30, 45), hasTime: true })
})

it('parses YYYY-MM-DDTHH:MM with timezone (wall clock in tz)', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-05-14T14:30', 'Australia/Perth')
// 14:30 AWST (UTC+8) = 06:30 UTC
expect(result).toEqual({ ms: Date.UTC(2025, 4, 14, 6, 30, 0), hasTime: true })
})

it('parses full ISO with Z offset as absolute instant', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-03-15T14:30:00Z')
expect(result).toEqual({ ms: Date.UTC(2025, 2, 15, 14, 30, 0), hasTime: true })
})

it('parses full ISO with explicit offset as absolute instant', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
const result = parseDueDate('2025-05-14T14:30:00+08:00')
// 14:30 +08:00 = 06:30 UTC
expect(result).toEqual({ ms: Date.UTC(2025, 4, 14, 6, 30, 0), hasTime: true })
})

it('ISO with offset ignores the timezone argument', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
// Explicit +08:00 wins over the America/New_York hint.
const result = parseDueDate('2025-05-14T14:30:00+08:00', 'America/New_York')
expect(result).toEqual({ ms: Date.UTC(2025, 4, 14, 6, 30, 0), hasTime: true })
})

it('throws on invalid date format', async () => {
Expand All @@ -201,6 +240,12 @@ describe('parseDueDate', () => {
expect(() => parseDueDate('2025-02')).toThrow('YYYY-MM-DD')
expect(() => parseDueDate('2025')).toThrow('YYYY-MM-DD')
})

it('throws on malformed time component', async () => {
const { parseDueDate } = await import('../../../src/commands/update.js')
expect(() => parseDueDate('2025-03-15T14')).toThrow('YYYY-MM-DD')
expect(() => parseDueDate('2025-03-15T25:00')).toThrow('YYYY-MM-DD')
})
})

describe('parseAssigneeId', () => {
Expand Down Expand Up @@ -366,6 +411,28 @@ describe('buildUpdatePayload', () => {
expect(payload.start_date_time).toBe(false)
})

it('builds payload with due date + time-of-day sets due_date_time=true', async () => {
const { buildUpdatePayload } = await import('../../../src/commands/update.js')
const payload = buildUpdatePayload({ dueDate: '2025-05-14T14:30' })
expect(payload.due_date).toBe(Date.UTC(2025, 4, 14, 14, 30, 0))
expect(payload.due_date_time).toBe(true)
})

it('builds payload with start_date + time-of-day in timezone', async () => {
const { buildUpdatePayload } = await import('../../../src/commands/update.js')
const payload = buildUpdatePayload({ startDate: '2025-05-14T14:30' }, 'Australia/Perth')
// 14:30 AWST (UTC+8) = 06:30 UTC
expect(payload.start_date).toBe(Date.UTC(2025, 4, 14, 6, 30, 0))
expect(payload.start_date_time).toBe(true)
})

it('builds payload with full ISO offset preserves absolute instant', async () => {
const { buildUpdatePayload } = await import('../../../src/commands/update.js')
const payload = buildUpdatePayload({ dueDate: '2025-05-14T14:30:00+08:00' })
expect(payload.due_date).toBe(Date.UTC(2025, 4, 14, 6, 30, 0))
expect(payload.due_date_time).toBe(true)
})

it('clears due_date when --due-date is "none"', async () => {
const { buildUpdatePayload } = await import('../../../src/commands/update.js')
const payload = buildUpdatePayload({ dueDate: 'none' })
Expand Down