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
2 changes: 2 additions & 0 deletions skills/todoist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ td notification read --all --yes
td reminder list "Plan sprint"
td reminder list --type time
td reminder add "Plan sprint" --before 30m
td reminder add "Plan sprint" --at "2026-06-01 09:00" --urgent # iOS full-screen alarm
td reminder update id:123 --before 1h
td reminder update id:123 --no-urgent # toggle urgency without changing time
td reminder delete id:123 --yes
td reminder get id:123
td reminder location add "Plan sprint" --name "Office" --lat 40.7128 --long -74.0060 --trigger on_enter --radius 100 # radius in meters
Expand Down
11 changes: 8 additions & 3 deletions src/commands/reminder/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { CliError } from '../../lib/errors.js'
import { isQuiet } from '../../lib/global-args.js'
import { formatJson, printDryRun } from '../../lib/output.js'
import { resolveTaskRef } from '../../lib/refs.js'
import { parseDateTime } from './helpers.js'
import { formatUrgentBadge, parseDateTime } from './helpers.js'

interface AddOptions {
before?: string
at?: string
urgent?: boolean
json?: boolean
dryRun?: boolean
}
Expand Down Expand Up @@ -69,6 +70,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise
Task: task.content,
Before: options.before,
At: options.at,
Urgent: options.urgent === undefined ? undefined : String(options.urgent),
})
return
}
Expand All @@ -77,6 +79,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise
itemId: task.id,
minuteOffset,
due,
isUrgent: options.urgent,
})

if (options.json) {
Expand All @@ -86,6 +89,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise
type: minuteOffset !== undefined ? 'relative' : 'absolute',
minuteOffset,
due,
isUrgent: options.urgent,
Comment thread
scottlovegrove marked this conversation as resolved.
isDeleted: false,
}
console.log(formatJson(reminder, 'reminder'))
Expand All @@ -97,10 +101,11 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise
return
}

const urgent = formatUrgentBadge(options.urgent)
if (minuteOffset !== undefined) {
console.log(`Added reminder: ${formatDuration(minuteOffset)} before due`)
console.log(`Added reminder: ${formatDuration(minuteOffset)} before due${urgent}`)
} else if (due) {
console.log(`Added reminder: at ${due.date.replace('T', ' ')}`)
console.log(`Added reminder: at ${due.date.replace('T', ' ')}${urgent}`)
}
console.log(chalk.dim(`ID: ${reminderId}`))
}
15 changes: 5 additions & 10 deletions src/commands/reminder/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import chalk from 'chalk'
import { getReminderById } from '../../lib/api/reminders.js'
import { formatJson } from '../../lib/output.js'
import { lenientIdRef } from '../../lib/refs.js'
import { formatReminderTime } from './helpers.js'
import { type TimeReminder, formatReminderTime, formatUrgentBadge } from './helpers.js'

interface GetOptions {
json?: boolean
Expand All @@ -11,7 +11,7 @@ interface GetOptions {

export async function getReminderCmd(reminderId: string, options: GetOptions): Promise<void> {
const id = lenientIdRef(reminderId, 'reminder')
const reminder = await getReminderById(id)
const reminder = (await getReminderById(id)) as TimeReminder

if (options.json) {
console.log(formatJson(reminder, 'reminder', options.full))
Expand All @@ -20,13 +20,8 @@ export async function getReminderCmd(reminderId: string, options: GetOptions): P

const idStr = chalk.dim(reminder.id)
const type = chalk.cyan('[time]')
const time = formatReminderTime(
reminder as {
type: 'relative' | 'absolute'
minuteOffset?: number
due?: { date: string }
},
)
console.log(`${idStr} ${type} ${time}`)
const time = formatReminderTime(reminder)
const urgent = formatUrgentBadge(reminder.isUrgent)
console.log(`${idStr} ${type}${urgent} ${time}`)
console.log(chalk.dim(`Task: ${reminder.itemId}`))
}
10 changes: 10 additions & 0 deletions src/commands/reminder/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,26 @@ import {
type LocationTrigger,
type Reminder,
} from '@doist/todoist-sdk'
import chalk from 'chalk'
import type { ReminderDue } from '../../lib/api/reminders.js'
import { formatDuration } from '../../lib/duration.js'
import { CliError } from '../../lib/errors.js'

export type ReminderTypeFilter = 'time' | 'location'

// `td reminder get` and time-only `list` rows never return location reminders,
// so we narrow the SDK union once and reuse it across both commands.
export type TimeReminder = Extract<Reminder, { type: 'absolute' | 'relative' }>

interface ReminderLike {
type: Reminder['type']
minuteOffset?: number
due?: { date: string }
isUrgent?: boolean
}

export function formatUrgentBadge(isUrgent: boolean | undefined): string {
return isUrgent ? ` ${chalk.red('[urgent]')}` : ''
}

export function formatReminderTime(reminder: ReminderLike): string {
Expand Down
6 changes: 6 additions & 0 deletions src/commands/reminder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export function registerReminderCommand(program: Command): void {
.option('--task <ref>', 'Task reference (name or id:xxx)')
.option('--before <duration>', 'Time before due (e.g., 30m, 1h)')
.option('--at <datetime>', 'Specific time (e.g., 2024-01-15 10:00)')
.option('--urgent', 'Mark reminder as urgent (iOS full-screen alarm)')
.option('--no-urgent', 'Mark reminder as not urgent')
.option('--json', 'Output the created reminder as JSON')
.option('--dry-run', 'Preview what would happen without executing')
.action(
Expand All @@ -65,6 +67,7 @@ export function registerReminderCommand(program: Command): void {
options: {
before?: string
at?: string
urgent?: boolean
json?: boolean
dryRun?: boolean
task?: string
Expand All @@ -90,6 +93,9 @@ export function registerReminderCommand(program: Command): void {
.description('Update a reminder')
.option('--before <duration>', 'Time before due (e.g., 30m, 1h)')
.option('--at <datetime>', 'Specific time (e.g., 2024-01-15 10:00)')
.option('--urgent', 'Mark reminder as urgent (iOS full-screen alarm)')
Comment thread
scottlovegrove marked this conversation as resolved.
.option('--no-urgent', 'Mark reminder as not urgent')
.option('--json', 'Output the updated reminder as JSON')
.option('--dry-run', 'Preview what would happen without executing')
.action((id, options) => {
if (!id) {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/reminder/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { paginate } from '../../lib/pagination.js'
import { resolveTaskRef } from '../../lib/refs.js'
import {
type ReminderTypeFilter,
type TimeReminder,
formatLocationReminderRow,
formatReminderTime,
formatUrgentBadge,
} from './helpers.js'

interface ListOptions extends PaginatedViewOptions {
Expand Down Expand Up @@ -117,12 +119,13 @@ export async function listReminders(

const showTask = !taskId

for (const reminder of reminders) {
for (const reminder of reminders as TimeReminder[]) {
const id = chalk.dim(reminder.id)
const type = chalk.cyan('[time]')
const time = formatReminderTime(reminder)
const urgent = formatUrgentBadge(reminder.isUrgent)
const task = showTask ? chalk.dim(` (task:${reminder.itemId})`) : ''
console.log(`${id} ${type} ${time}${task}`)
console.log(`${id} ${type}${urgent} ${time}${task}`)
}

for (const loc of locationReminders) {
Expand Down
165 changes: 165 additions & 0 deletions src/commands/reminder/reminder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,32 @@ describe('reminder list', () => {
).rejects.toThrow('--cursor requires --type')
})

it('shows [urgent] badge for urgent reminders', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockApi.getReminders.mockResolvedValue({
results: [
{
id: 'rem-1',
notifyUid: 'user-1',
itemId: 'task-1',
type: 'relative',
minuteOffset: 30,
isUrgent: true,
isDeleted: false,
},
],
nextCursor: null,
})
mockApi.getLocationReminders.mockResolvedValue({ results: [], nextCursor: null })

await program.parseAsync(['node', 'td', 'reminder', 'list'])

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]'))
consoleSpy.mockRestore()
})

it('does not show task context when filtered by task', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
Expand Down Expand Up @@ -616,6 +642,31 @@ describe('reminder add', () => {
]),
).rejects.toThrow('Cannot specify task both as argument and --task flag')
})

it('passes --urgent through and shows [urgent] in output', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockApi.getTask.mockResolvedValue({ id: 'task-1', content: 'Buy milk' })
mockAddReminder.mockResolvedValue('rem-new')

await program.parseAsync([
'node',
'td',
'reminder',
'add',
'id:task-1',
'--at',
'2024-01-15 10:00',
'--urgent',
])

expect(mockAddReminder).toHaveBeenCalledWith(
expect.objectContaining({ itemId: 'task-1', isUrgent: true }),
)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]'))
consoleSpy.mockRestore()
})
})

describe('reminder update', () => {
Expand Down Expand Up @@ -678,6 +729,77 @@ describe('reminder update', () => {
]),
).rejects.toHaveProperty('code', 'INVALID_REF')
})

it('toggles urgency alone without --before or --at', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockUpdateReminder.mockResolvedValue(undefined)

await program.parseAsync(['node', 'td', 'reminder', 'update', 'id:rem-1', '--no-urgent'])

expect(mockUpdateReminder).toHaveBeenCalledWith(
'rem-1',
expect.objectContaining({ isUrgent: false }),
)
expect(consoleSpy).toHaveBeenCalledWith('Updated reminder: marked not urgent (id:rem-1)')
consoleSpy.mockRestore()
})

it('shows [urgent] in confirmation when time and --urgent are combined', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockUpdateReminder.mockResolvedValue(undefined)

await program.parseAsync([
'node',
'td',
'reminder',
'update',
'id:rem-1',
'--before',
'1h',
'--urgent',
])

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1h before due'))
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]'))
consoleSpy.mockRestore()
})

it('outputs JSON with --json', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockUpdateReminder.mockResolvedValue(undefined)
mockGetReminderById.mockResolvedValue({
id: 'rem-1',
notifyUid: 'user-1',
itemId: 'task-1',
type: 'relative',
minuteOffset: 60,
isUrgent: true,
isDeleted: false,
// biome-ignore lint/suspicious/noExplicitAny: mock
} as any)

await program.parseAsync([
'node',
'td',
'reminder',
'update',
'id:rem-1',
'--urgent',
'--json',
])

const firstCall = consoleSpy.mock.calls[0]?.[0] as string
const parsed = JSON.parse(firstCall)
expect(parsed.id).toBe('rem-1')
expect(parsed.isUrgent).toBe(true)
consoleSpy.mockRestore()
})
})

describe('reminder delete', () => {
Expand Down Expand Up @@ -876,6 +998,49 @@ describe('reminder get', () => {
program.parseAsync(['node', 'td', 'reminder', 'get', 'not-a-valid-id!']),
).rejects.toMatchObject({ code: 'INVALID_REF' })
})

it('shows [urgent] badge for urgent reminders', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockGetReminderById.mockResolvedValue({
id: 'rem-1',
notifyUid: 'user-1',
itemId: 'task-1',
type: 'relative',
minuteOffset: 30,
isUrgent: true,
isDeleted: false,
// biome-ignore lint/suspicious/noExplicitAny: mock
} as any)

await program.parseAsync(['node', 'td', 'reminder', 'get', 'id:rem-1'])

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]'))
consoleSpy.mockRestore()
})

it('includes isUrgent in default --json output', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

mockGetReminderById.mockResolvedValue({
id: 'rem-1',
notifyUid: 'user-1',
itemId: 'task-1',
type: 'relative',
minuteOffset: 30,
isUrgent: true,
isDeleted: false,
// biome-ignore lint/suspicious/noExplicitAny: mock
} as any)

await program.parseAsync(['node', 'td', 'reminder', 'get', 'id:rem-1', '--json'])

const firstCall = consoleSpy.mock.calls[0]?.[0] as string
expect(firstCall).toContain('"isUrgent": true')
consoleSpy.mockRestore()
})
})

describe('reminder location add', () => {
Expand Down
Loading
Loading