From c7ca0916f09ecace0d670f6e3ce6fd96faec619b Mon Sep 17 00:00:00 2001 From: thecodeartificerX Date: Thu, 14 May 2026 15:09:53 +0800 Subject: [PATCH] feat(dates): accept time-of-day on --due-date and --start-date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, --due-date and --start-date only accepted YYYY-MM-DD and always wrote the task with due_date_time/start_date_time = false. This made it impossible to set tasks at a specific time of day from the CLI, even though the ClickUp API natively supports it. This extends parseDueDate to accept three input shapes, backward compatible: - YYYY-MM-DD (existing behavior; date-only) - YYYY-MM-DDTHH:MM[:SS] (wall clock in user's ClickUp timezone) - YYYY-MM-DDTHH:MM[:SS]±HH:MM | Z (absolute ISO 8601 instant) When the input includes a time component, the payload's due_date_time / start_date_time is set to true, so ClickUp displays the task with time and the Google Calendar integration emits a timed event rather than an all-day one. Changes: - parseDueDate now returns { ms, hasTime } instead of number; callers (update, create, bulk, goals) use the new shape. - Reuses the existing IANA-timezone offset technique, generalized from midnight-only to arbitrary wall-clock times. - Range-checks HH/MM/SS to reject e.g. "T25:00". - Help text on --due-date / --start-date updated in update + create. - 9 new unit tests covering date-only, local datetime, datetime+tz, full ISO with Z, full ISO with explicit offset, and malformed inputs. Tests: 70/70 update tests pass; full suite 1107 passing with 4 pre-existing failures (config/interactive/completion-doc-sync) untouched by this change. --- src/commands/bulk.ts | 11 ++-- src/commands/create.ts | 10 +-- src/commands/goals.ts | 2 +- src/commands/update.ts | 100 +++++++++++++++++++++++------ src/index.ts | 20 ++++-- tests/unit/commands/update.test.ts | 73 ++++++++++++++++++++- 6 files changed, 179 insertions(+), 37 deletions(-) diff --git a/src/commands/bulk.ts b/src/commands/bulk.ts index c39725d..fd1ff98 100644 --- a/src/commands/bulk.ts +++ b/src/commands/bulk.ts @@ -53,10 +53,13 @@ export async function bulkDueDate( ): Promise { 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), ) diff --git a/src/commands/create.ts b/src/commands/create.ts index c06b1ab..fd4904f 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -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)] diff --git a/src/commands/goals.ts b/src/commands/goals.ts index e4d06a1..282967e 100644 --- a/src/commands/goals.ts +++ b/src/commands/goals.ts @@ -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 } : {}), }) } diff --git a/src/commands/update.ts b/src/commands/update.ts index e89613f..2e5b96f 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -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, @@ -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 } @@ -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 = {} diff --git a/src/index.ts b/src/index.ts index a1987c8..ec68809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ', 'Priority: urgent, high, normal, low (or 1-4)') - .option('--due-date ', 'Due date (YYYY-MM-DD, or "none"/"clear" to remove)') - .option('--start-date ', 'Start date (YYYY-MM-DD)') + .option( + '--due-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 ', + 'Start date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)', + ) .option( '--time-estimate ', 'Time estimate (e.g. "2h", "30m", "1h30m", "0" or "none" to clear)', @@ -480,8 +486,14 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')): .option('-p, --parent ', 'Parent task ID (list auto-detected from parent)') .option('-s, --status ', 'Initial status') .option('--priority ', 'Priority: urgent, high, normal, low (or 1-4)') - .option('--due-date ', 'Due date (YYYY-MM-DD)') - .option('--start-date ', 'Start date (YYYY-MM-DD)') + .option( + '--due-date ', + 'Due date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)', + ) + .option( + '--start-date ', + 'Start date (YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS], or full ISO 8601 with offset)', + ) .option('--assignee ', 'Assignee user ID or "me"') .option('--tags ', 'Comma-separated tag names') .option('--custom-item-id ', 'Custom task type ID (use to create initiatives)') diff --git a/tests/unit/commands/update.test.ts b/tests/unit/commands/update.test.ts index 7097fd3..dac6c0a 100644 --- a/tests/unit/commands/update.test.ts +++ b/tests/unit/commands/update.test.ts @@ -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 () => { @@ -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', () => { @@ -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' })