Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions bin/ticktick.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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) {
Expand All @@ -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());
Expand Down
31 changes: 29 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
172 changes: 159 additions & 13 deletions lib/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 || [],
},
};
}
Expand Down Expand Up @@ -454,6 +466,139 @@ async function resolveProjectId(projectId, deps = {}) {
* @param {string} projectId - Optional project ID to search within
* @returns {Promise<string>} - 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
Expand All @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'];
Expand Down
Loading