diff --git a/README.md b/README.md index 728330d..dd9dd14 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ Create `~/.config/ticktick/config.json`: "clientId": "YOUR_CLIENT_ID", "clientSecret": "YOUR_CLIENT_SECRET", "redirectUri": "http://localhost:18888/callback", - "region": "global" + "region": "global", + "timezone": "Asia/Hong_Kong" } ``` @@ -86,6 +87,8 @@ Or set environment variables: ```bash export TICKTICK_CLIENT_ID="your_client_id" export TICKTICK_CLIENT_SECRET="your_client_secret" +export TICKTICK_REGION="global" +export TICKTICK_TIMEZONE="Asia/Hong_Kong" ``` #### 3. Authenticate diff --git a/bin/ticktick.js b/bin/ticktick.js index 6041cb8..aab21ce 100755 --- a/bin/ticktick.js +++ b/bin/ticktick.js @@ -17,6 +17,7 @@ import * as tasks from '../lib/tasks.js'; import * as projects from '../lib/projects.js'; import { promptTaskCreate } from '../lib/interactive.js'; import { runSetup } from '../lib/setup.js'; +import { loadConfig } from '../lib/core.js'; const args = parseArgs(process.argv.slice(2)); @@ -57,6 +58,7 @@ async function main() { // Output result if any if (result !== undefined) { + await applyConfiguredTimezone(args.options.format); console.log(formatOutput(result, args.options.format)); } } catch (error) { @@ -65,6 +67,16 @@ async function main() { } } + +async function applyConfiguredTimezone(format) { + if (format === 'json' || process.env.TICKTICK_TIMEZONE) return; + + try { + const config = await loadConfig(); + if (config.timezone) process.env.TICKTICK_TIMEZONE = config.timezone; + } catch {} +} + async function handleAuth() { if (args.options.help || !args.subcommand) { console.log(getAuthHelp()); diff --git a/lib/cli.js b/lib/cli.js index d6b21d2..ce517e4 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -105,7 +105,7 @@ function formatArray(arr) { for (const item of arr) { const id = (item.id || '').padEnd(8); const title = truncate(item.title || '', 30).padEnd(30); - const due = (item.dueDate ? item.dueDate.slice(0, 10) : '').padEnd(10); + const due = formatDueDate(item.dueDate).padEnd(10); const pri = (item.priority || 'none').padEnd(6); const tags = (item.tags || []).join(', '); lines.push(`${id} | ${title} | ${due} | ${pri} | ${tags}`); @@ -203,7 +203,7 @@ function formatTaskDetail(task) { lines.push(`ID: ${task.id}${task.fullId ? ` (${task.fullId})` : ''}`); if (task.projectId) lines.push(`Project: ${task.projectId}`); if (task.content) lines.push(`Description: ${task.content}`); - if (task.dueDate) lines.push(`Due: ${task.dueDate}`); + if (task.dueDate) lines.push(`Due: ${formatDueDate(task.dueDate)}`); lines.push(`Priority: ${task.priority || 'none'}`); if (task.tags?.length) lines.push(`Tags: ${task.tags.join(', ')}`); if (task.status) lines.push(`Status: ${task.status}`); @@ -238,6 +238,33 @@ function formatAuthStatus(status) { return lines.join('\n'); } + +/** + * Format due dates in the local timezone for text output. + * Date-only values are preserved as-is. + */ +function formatDueDate(dueDate) { + if (!dueDate) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) return dueDate; + + const parsed = new Date(dueDate); + if (Number.isNaN(parsed.getTime())) return dueDate; + + const timezone = process.env.TICKTICK_TIMEZONE; + if (timezone) { + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(parsed); + } catch { + return dueDate; + } + } + + const year = parsed.getFullYear(); + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + /** * Truncate string to max length */ diff --git a/lib/core.js b/lib/core.js index 48fcb1a..d161e5f 100644 --- a/lib/core.js +++ b/lib/core.js @@ -64,6 +64,7 @@ export async function loadConfig(deps = {}) { clientSecret: process.env.TICKTICK_CLIENT_SECRET, redirectUri: process.env.TICKTICK_REDIRECT_URI || 'http://localhost:18888/callback', region: process.env.TICKTICK_REGION || 'global', + timezone: process.env.TICKTICK_TIMEZONE, }; } diff --git a/lib/tasks.js b/lib/tasks.js index 18d4900..2c3ed7f 100644 --- a/lib/tasks.js +++ b/lib/tasks.js @@ -94,7 +94,8 @@ export async function create(projectId, title, options = {}, deps = {}) { const input = { title: title.trim(), projectId: resolvedProjectId }; if (options.content) input.content = options.content; - if (options.dueDate) input.dueDate = options.dueDate; + const dueInput = normalizeDueDateInput(options.dueDate, deps); + Object.assign(input, dueInput); if (options.priority) input.priority = parsePriority(options.priority); if (options.tags) input.tags = Array.isArray(options.tags) ? options.tags : options.tags.split(',').map((t) => t.trim()); if (options.reminder) { @@ -131,12 +132,15 @@ export async function update(taskId, options = {}, deps = {}) { parseReminder = coreFunctions.parseReminder, parsePriority = coreFunctions.parsePriority, } = deps; - const resolvedTaskId = await resolveTaskId(taskId, null, deps); + const resolvedTask = await resolveTaskRecord(taskId, null, deps); + const resolvedTaskId = resolvedTask.id; const input = { id: resolvedTaskId }; + if (resolvedTask.projectId) input.projectId = resolvedTask.projectId; if (options.title) input.title = options.title; if (options.content) input.content = options.content; - if (options.dueDate) input.dueDate = options.dueDate; + const dueInput = normalizeDueDateInput(options.dueDate, deps); + Object.assign(input, dueInput); if (options.priority) input.priority = parsePriority(options.priority); if (options.tags) input.tags = Array.isArray(options.tags) ? options.tags : options.tags.split(',').map((t) => t.trim()); if (options.reminder) { @@ -145,16 +149,24 @@ export async function update(taskId, options = {}, deps = {}) { } const task = await apiRequest('POST', `/task/${encodeURIComponent(resolvedTaskId)}`, input, deps); + const resultTask = task || { + id: resolvedTaskId, + projectId: resolvedTask.projectId, + title: options.title || resolvedTask.title, + dueDate: dueInput.dueDate, + priority: options.priority ? parsePriority(options.priority) : 0, + tags: Array.isArray(options.tags) ? options.tags : options.tags ? options.tags.split(',').map((t) => t.trim()) : [], + }; return { success: true, task: { - id: shortId(task.id), - fullId: task.id, - projectId: shortId(task.projectId), - title: task.title, - dueDate: task.dueDate, - priority: formatPriority(task.priority), - tags: task.tags || [], + id: shortId(resultTask.id), + fullId: resultTask.id, + projectId: resultTask.projectId ? shortId(resultTask.projectId) : undefined, + title: resultTask.title, + dueDate: resultTask.dueDate, + priority: formatPriority(resultTask.priority), + tags: resultTask.tags || [], }, }; } @@ -454,6 +466,139 @@ async function resolveProjectId(projectId, deps = {}) { * @param {string} projectId - Optional project ID to search within * @returns {Promise} - Full task ID */ +function normalizeDueDateInput(dueDate, deps = {}) { + if (!dueDate) return {}; + if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) { + return { dueDate, isAllDay: false }; + } + + const timeZone = resolveTimeZone(deps); + const normalized = localDateToApiDateTime(dueDate, timeZone); + return { + dueDate: normalized, + startDate: normalized, + isAllDay: true, + timeZone, + }; +} + +function resolveTimeZone(deps = {}) { + if (typeof deps.getTimeZone === 'function') { + return deps.getTimeZone(); + } + return process.env.TICKTICK_TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +function localDateToApiDateTime(dateStr, timeZone) { + const [year, month, day] = dateStr.split('-').map(Number); + const utcGuess = Date.UTC(year, month - 1, day, 0, 0, 0); + let offset = getTimeZoneOffsetMillis(new Date(utcGuess), timeZone); + let utcMillis = utcGuess - offset; + const adjustedOffset = getTimeZoneOffsetMillis(new Date(utcMillis), timeZone); + if (adjustedOffset !== offset) { + utcMillis = utcGuess - adjustedOffset; + } + return formatApiDateTime(new Date(utcMillis)); +} + +function getTimeZoneOffsetMillis(date, timeZone) { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const parts = Object.fromEntries(formatter.formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])); + const normalizedHour = parts.hour === '24' ? '00' : parts.hour; + const asUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(normalizedHour), + Number(parts.minute), + Number(parts.second), + ); + return asUtc - date.getTime(); +} + +function formatApiDateTime(date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hour = String(date.getUTCHours()).padStart(2, '0'); + const minute = String(date.getUTCMinutes()).padStart(2, '0'); + const second = String(date.getUTCSeconds()).padStart(2, '0'); + return `${year}-${month}-${day}T${hour}:${minute}:${second}+0000`; +} + +async function resolveTaskRecord(taskId, projectId = null, deps = {}) { + const { apiRequest = coreFunctions.apiRequest, isShortId = coreFunctions.isShortId } = deps; + let resolvedProjectId = null; + if (projectId) { + resolvedProjectId = await resolveProjectId(projectId, deps); + } + + if (!isShortId(taskId) && resolvedProjectId) { + return { id: taskId, projectId: resolvedProjectId }; + } + if (!isShortId(taskId) && !resolvedProjectId) { + return { id: taskId, projectId: null }; + } + + if (resolvedProjectId) { + try { + const data = await apiRequest('GET', `/project/${encodeURIComponent(resolvedProjectId)}/data`, undefined, deps); + const match = data.tasks.find((t) => t.id.startsWith(taskId)); + if (match) { + return hydrateTaskRecord(match, resolvedProjectId, deps); + } + } catch { + // Fall through to search all projects + } + } + + const projects = await apiRequest('GET', '/project', undefined, deps); + const projectCandidates = [...projects.map((project) => project.id), 'inbox']; + for (const candidateProjectId of projectCandidates) { + try { + const data = await apiRequest('GET', `/project/${encodeURIComponent(candidateProjectId)}/data`, undefined, deps); + const match = data.tasks.find((t) => t.id.startsWith(taskId)); + if (match) { + return hydrateTaskRecord(match, candidateProjectId, deps); + } + } catch { + // Skip projects we can't access + } + } + + return { id: taskId, projectId: resolvedProjectId }; +} + +async function hydrateTaskRecord(task, fallbackProjectId, deps = {}) { + const { apiRequest = coreFunctions.apiRequest, isShortId = coreFunctions.isShortId } = deps; + const resolvedProjectId = task.projectId && !isShortId(task.projectId) ? task.projectId : fallbackProjectId; + const shouldHydrate = isShortId(task.id) || (resolvedProjectId && isShortId(resolvedProjectId)); + + if (!shouldHydrate || !resolvedProjectId) { + return { ...task, projectId: resolvedProjectId || task.projectId }; + } + + try { + return await apiRequest( + 'GET', + `/project/${encodeURIComponent(resolvedProjectId)}/task/${encodeURIComponent(task.id)}`, + undefined, + deps + ); + } catch { + return { ...task, projectId: resolvedProjectId || task.projectId }; + } +} + async function resolveTaskId(taskId, projectId = null, deps = {}) { const { apiRequest = coreFunctions.apiRequest, isShortId = coreFunctions.isShortId } = deps; // If it looks like a full ID, return as-is @@ -475,12 +620,13 @@ async function resolveTaskId(taskId, projectId = null, deps = {}) { } } - // Search all projects + // Search all projects, plus the inbox virtual project which is omitted from /project const projects = await apiRequest('GET', '/project', undefined, deps); + const projectCandidates = [...projects.map((project) => project.id), 'inbox']; - for (const project of projects) { + for (const candidateProjectId of projectCandidates) { try { - const data = await apiRequest('GET', `/project/${encodeURIComponent(project.id)}/data`, undefined, deps); + const data = await apiRequest('GET', `/project/${encodeURIComponent(candidateProjectId)}/data`, undefined, deps); const match = data.tasks.find((t) => t.id.startsWith(taskId)); if (match) { return match.id; diff --git a/test/cli.test.js b/test/cli.test.js index d58f437..bd94fa4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { formatOutput } from '../lib/cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = join(__dirname, '..', 'bin', 'ticktick.js'); @@ -205,6 +206,57 @@ describe('parseArgs behavior', () => { }); }); +describe('Text output date formatting', () => { + test('converts ISO due dates into the configured timezone for task tables', () => { + process.env.TICKTICK_TIMEZONE = 'Asia/Hong_Kong'; + + const output = formatOutput([ + { + id: 'task1234', + title: 'Birthday reminder', + dueDate: '2026-06-24T16:00:00.000+0000', + priority: 'none', + tags: [], + }, + ], 'text'); + + assert.ok(output.includes('2026-06-25')); + delete process.env.TICKTICK_TIMEZONE; + }); + + + test('returns the raw due date when timezone is invalid', () => { + process.env.TICKTICK_TIMEZONE = 'Mars/Olympus_Mons'; + + const output = formatOutput([ + { + id: 'task9999', + title: 'Timezone fallback', + dueDate: '2026-06-24T16:00:00.000+0000', + priority: 'none', + tags: [], + }, + ], 'text'); + + assert.ok(output.includes('2026-06-24T16:00:00.000+0000')); + delete process.env.TICKTICK_TIMEZONE; + }); + + test('preserves date-only due dates in text output', () => { + const output = formatOutput([ + { + id: 'task1234', + title: 'All-day event', + dueDate: '2026-06-24', + priority: 'none', + tags: [], + }, + ], 'text'); + + assert.ok(output.includes('2026-06-24')); + }); +}); + describe('Due date handling', () => { test('date-only format is accepted', () => { const validDates = ['2026-01-25', '2026-12-31', '2027-01-01']; diff --git a/test/core-extended.test.js b/test/core-extended.test.js index d15e70b..6c0b85e 100644 --- a/test/core-extended.test.js +++ b/test/core-extended.test.js @@ -85,6 +85,7 @@ describe('Config and Token File Operations', () => { TICKTICK_CLIENT_SECRET: process.env.TICKTICK_CLIENT_SECRET, TICKTICK_REDIRECT_URI: process.env.TICKTICK_REDIRECT_URI, TICKTICK_REGION: process.env.TICKTICK_REGION, + TICKTICK_TIMEZONE: process.env.TICKTICK_TIMEZONE, }; // Clear env vars that might interfere @@ -92,6 +93,7 @@ describe('Config and Token File Operations', () => { delete process.env.TICKTICK_CLIENT_SECRET; delete process.env.TICKTICK_REDIRECT_URI; delete process.env.TICKTICK_REGION; + delete process.env.TICKTICK_TIMEZONE; // Set XDG_CONFIG_HOME to our temp directory process.env.XDG_CONFIG_HOME = tempDir; @@ -119,6 +121,7 @@ describe('Config and Token File Operations', () => { process.env.TICKTICK_CLIENT_SECRET = 'env-client-secret'; process.env.TICKTICK_REDIRECT_URI = 'http://custom:9999/cb'; process.env.TICKTICK_REGION = 'china'; + process.env.TICKTICK_TIMEZONE = 'Asia/Hong_Kong'; // Need fresh import to pick up new CONFIG_DIR const configDir = join(tempDir, 'ticktick'); @@ -133,6 +136,7 @@ describe('Config and Token File Operations', () => { clientSecret: 'env-client-secret', redirectUri: 'http://custom:9999/cb', region: 'china', + timezone: 'Asia/Hong_Kong', }); }); @@ -145,6 +149,7 @@ describe('Config and Token File Operations', () => { assert.equal(config.redirectUri, 'http://localhost:18888/callback'); assert.equal(config.region, 'global'); + assert.equal(config.timezone, undefined); }); test('loads config from file when env vars not set', async () => { @@ -156,6 +161,7 @@ describe('Config and Token File Operations', () => { clientSecret: 'file-client-secret', redirectUri: 'http://file:8080/callback', region: 'global', + timezone: 'Asia/Hong_Kong', }; await writeFile( @@ -382,6 +388,7 @@ describe('OAuth and API Functions (with fetch mocks)', () => { TICKTICK_CLIENT_SECRET: process.env.TICKTICK_CLIENT_SECRET, TICKTICK_REDIRECT_URI: process.env.TICKTICK_REDIRECT_URI, TICKTICK_REGION: process.env.TICKTICK_REGION, + TICKTICK_TIMEZONE: process.env.TICKTICK_TIMEZONE, }; // Set up test environment @@ -772,6 +779,7 @@ describe('OAuth and API Functions (with fetch mocks)', () => { test('uses China API URL when region is china', async () => { // Set region to china process.env.TICKTICK_REGION = 'china'; + process.env.TICKTICK_TIMEZONE = 'Asia/Hong_Kong'; const configDir = join(tempDir, 'ticktick'); await mkdir(configDir, { recursive: true }); diff --git a/test/tasks.test.js b/test/tasks.test.js index b5da8b7..161785e 100644 --- a/test/tasks.test.js +++ b/test/tasks.test.js @@ -31,6 +31,7 @@ const makeDeps = (apiRequest) => ({ if (reminder === '1h') return 'TRIGGER:-PT1H'; return null; }, + getTimeZone: () => 'Asia/Shanghai', }); describe('tasks.list', () => { @@ -226,7 +227,10 @@ describe('tasks.create', () => { const body = postCall.arguments[2]; assert.equal(body.title, 'Important Task'); assert.equal(body.content, 'Task description'); - assert.equal(body.dueDate, '2026-02-01'); + assert.equal(body.dueDate, '2026-01-31T16:00:00+0000'); + assert.equal(body.startDate, '2026-01-31T16:00:00+0000'); + assert.equal(body.isAllDay, true); + assert.equal(body.timeZone, 'Asia/Shanghai'); assert.equal(body.priority, 5); // high = 5 assert.deepEqual(body.tags, ['urgent', 'work']); assert.deepEqual(body.reminders, ['TRIGGER:-PT1H']); @@ -286,6 +290,137 @@ describe('tasks.update', () => { assert.equal(postCall.arguments[2].priority, 3); // medium = 3 }); + test('includes resolved projectId in update request body', async () => { + const mockApiRequest = mock.fn(async (method, path, body) => { + if (method === 'POST' && path.includes('/task/')) { + return { id: body.id, projectId: body.projectId, title: 'Task', priority: 0, tags: [] }; + } + if (path === '/project') { + return [{ id: 'proj123456789', name: 'Work' }]; + } + if (path.includes('/data')) { + return { tasks: [{ id: 'task123456789', projectId: 'proj123456789', title: 'Task' }] }; + } + return {}; + }); + + await tasks.update('task1234', { dueDate: '2026-03-01' }, makeDeps(mockApiRequest)); + + const postCall = mockApiRequest.mock.calls.find(c => + c.arguments[0] === 'POST' && c.arguments[1].includes('/task/') + ); + const body = postCall.arguments[2]; + assert.equal(body.id, 'task123456789'); + assert.equal(body.projectId, 'proj123456789'); + assert.equal(body.dueDate, '2026-02-28T16:00:00+0000'); + assert.equal(body.startDate, '2026-02-28T16:00:00+0000'); + assert.equal(body.isAllDay, true); + assert.equal(body.timeZone, 'Asia/Shanghai'); + }); + + test('hydrates short task ids to full ids before update', async () => { + const mockApiRequest = mock.fn(async (method, path, body) => { + if (method === 'POST' && path.includes('/task/')) { + return { id: body.id, projectId: body.projectId, title: 'Task', dueDate: body.dueDate, priority: 0, tags: [] }; + } + if (path === '/project') { + return [{ id: 'proj123456789', name: 'Work' }]; + } + if (path.includes('/project/proj123456789/data')) { + return { tasks: [{ id: 'task1234', projectId: 'proj1234', title: 'Task' }] }; + } + if (path.includes('/project/proj123456789/task/task1234')) { + return { id: 'task123456789', projectId: 'proj123456789', title: 'Task' }; + } + return {}; + }); + + await tasks.update('task1234', { dueDate: '2026-03-01' }, makeDeps(mockApiRequest)); + + const postCall = mockApiRequest.mock.calls.find(c => + c.arguments[0] === 'POST' && c.arguments[1].includes('/task/') + ); + assert.equal(postCall.arguments[1], '/task/task123456789'); + assert.equal(postCall.arguments[2].id, 'task123456789'); + assert.equal(postCall.arguments[2].projectId, 'proj123456789'); + }); + + + test('searches inbox virtual project when project list omits inbox', async () => { + const mockApiRequest = mock.fn(async (method, path, body) => { + if (method === 'POST' && path.includes('/task/')) { + return { id: body.id, projectId: body.projectId, title: 'Inbox Task', dueDate: body.dueDate, priority: 0, tags: [] }; + } + if (path === '/project') { + return [{ id: 'proj123456789', name: 'Work' }]; + } + if (path.includes('/project/proj123456789/data')) { + return { tasks: [] }; + } + if (path.includes('/project/inbox/data')) { + return { tasks: [{ id: 'task123456789', projectId: 'inbox1012607260', title: 'Inbox Task' }] }; + } + return {}; + }); + + await tasks.update('task1234', { dueDate: '2026-03-01' }, makeDeps(mockApiRequest)); + + const inboxLookup = mockApiRequest.mock.calls.find(c => c.arguments[1] === '/project/inbox/data'); + assert.ok(inboxLookup); + const postCall = mockApiRequest.mock.calls.find(c => + c.arguments[0] === 'POST' && c.arguments[1].includes('/task/') + ); + assert.equal(postCall.arguments[1], '/task/task123456789'); + assert.equal(postCall.arguments[2].projectId, 'inbox1012607260'); + }); + + test('datetime due input stays timed', async () => { + const mockApiRequest = mock.fn(async (method, path, body) => { + if (method === 'POST' && path.includes('/task/')) { + return { id: body.id, projectId: body.projectId, title: 'Task', dueDate: body.dueDate, priority: 0, tags: [] }; + } + if (path === '/project') { + return [{ id: 'proj123456789', name: 'Work' }]; + } + if (path.includes('/data')) { + return { tasks: [{ id: 'task123456789', projectId: 'proj123456789', title: 'Task' }] }; + } + return {}; + }); + + await tasks.update('task1234', { dueDate: '2026-03-01T09:30:00+0800' }, makeDeps(mockApiRequest)); + + const postCall = mockApiRequest.mock.calls.find(c => + c.arguments[0] === 'POST' && c.arguments[1].includes('/task/') + ); + const body = postCall.arguments[2]; + assert.equal(body.dueDate, '2026-03-01T09:30:00+0800'); + assert.equal(body.isAllDay, false); + assert.equal(body.startDate, undefined); + }); + + test('handles empty update response without crashing', async () => { + const mockApiRequest = mock.fn(async (method, path, body) => { + if (method === 'POST' && path.includes('/task/')) { + return undefined; + } + if (path === '/project') { + return [{ id: 'proj123456789', name: 'Work' }]; + } + if (path.includes('/data')) { + return { tasks: [{ id: 'task123456789', projectId: 'proj123456789', title: 'Task' }] }; + } + return {}; + }); + + const result = await tasks.update('task1234', { dueDate: '2026-03-01' }, makeDeps(mockApiRequest)); + + assert.equal(result.success, true); + assert.equal(result.task.fullId, 'task123456789'); + assert.equal(result.task.projectId, 'proj1234'); + assert.equal(result.task.dueDate, '2026-02-28T16:00:00+0000'); + }); + test('only includes provided fields in update', async () => { const mockApiRequest = mock.fn(async (method, path, body) => { if (method === 'POST' && path.includes('/task/')) { @@ -306,7 +441,10 @@ describe('tasks.update', () => { c.arguments[0] === 'POST' && c.arguments[1].includes('/task/') ); const body = postCall.arguments[2]; - assert.equal(body.dueDate, '2026-03-01'); + assert.equal(body.dueDate, '2026-02-28T16:00:00+0000'); + assert.equal(body.startDate, '2026-02-28T16:00:00+0000'); + assert.equal(body.isAllDay, true); + assert.equal(body.timeZone, 'Asia/Shanghai'); assert.equal(body.title, undefined); assert.equal(body.priority, undefined); });