Skip to content

Recurring task created via due_string with a starting clause never advances on completion #342

@aheckler

Description

@aheckler

Summary

A task created with a due_string that contains a starting clause has the entire string stored verbatim as its recurrence rule. Completing the task then never advances it — every completion re-pins the same date. The identical recurrence without starting advances correctly.

Todoist's quick-add parser (the app's "Add task" box, and td task quickadd) handles the same text correctly: it consumes starting <date> into the due date and stores a clean recurrence rule (every! 2 weeks). So Todoist has two natural-language date parsers — the due_string field parser and the quick-add parser — and they disagree on starting.

This is server-side — it reproduces with raw REST, no td involved (see below). Filing it here because td task add --due is likely a common way to hit it. td task quickadd is not affected.

Impact

Silent. A recurring task created via the API looks completely normal; the breakage only surfaces the first time you complete it — the task doesn't move, and never will. Anything that creates recurring tasks programmatically — td task add --due, the SDK, direct REST, bulk imports/migrations — is exposed.

Reproduce — td CLI (v1.63.0)

$ td task add "repro-cli" --due "every! 2 weeks starting 2026-05-17"
$ td task view "repro-cli" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks starting 2026-05-17","date":"2026-05-17","timezone":null,"lang":"en"}

$ td task complete "repro-cli"
$ td task view "repro-cli" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks starting 2026-05-17","date":"2026-05-17","timezone":null,"lang":"en"}
#                                                                  ^^^^^^^^^^^^^^^^^^ date UNCHANGED — did not advance

Control — td task quickadd parses the identical text correctly:

$ td task quickadd "repro-qa every! 2 weeks starting 2026-05-17"
$ td task view "repro-qa" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks","date":"2026-05-17","timezone":null,"lang":"en"}
#                            ^^^^^^^^^^^^^^^^ "starting" consumed into the date; clean recurrence rule stored

That task advances correctly on completion.

Reproduce — raw REST (no td; isolates it to the API)

TOKEN=...            # a Todoist API token
TODAY=$(date +%F)

# CASE 1 — due_string WITH a "starting" clause
ID=$(curl -s -X POST https://api.todoist.com/api/v1/tasks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"content\":\"repro-A\",\"due_string\":\"every! 2 weeks starting $TODAY\"}" | jq -r .id)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ID/close" -H "Authorization: Bearer $TOKEN"
curl -s "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN" | jq -c .due
#   => due date is UNCHANGED after completion
curl -s -X DELETE "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN"

# CASE 2 — identical recurrence WITHOUT "starting" (control)
ID=$(curl -s -X POST https://api.todoist.com/api/v1/tasks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"content":"repro-B","due_string":"every! 2 weeks"}' | jq -r .id)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ID/close" -H "Authorization: Bearer $TOKEN"
curl -s "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN" | jq -c .due
#   => due date advanced by 2 weeks
curl -s -X DELETE "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN"

Observed (run 2026-05-17):

CASE 1  due_string sent: "every! 2 weeks starting 2026-05-17"
  POST /tasks       -> stored due.string = "every! 2 weeks starting 2026-05-17"   due.date = 2026-05-17
  POST .../close    -> HTTP 204
  GET  /tasks/{id}  -> due.string = "every! 2 weeks starting 2026-05-17"   due.date = 2026-05-17   << UNCHANGED

CASE 2  due_string sent: "every! 2 weeks"
  POST /tasks       -> stored due.string = "every! 2 weeks"                due.date = 2026-05-17
  POST .../close    -> HTTP 204
  GET  /tasks/{id}  -> due.string = "every! 2 weeks"                       due.date = 2026-05-31   << advanced +2 weeks

Same endpoint, same auth, same completion call — the only variable is the starting clause.

Where the fault is

  • td task add --due "X" sends X verbatim as the task's due_string. Confirmed: the created task's due.string comes back byte-identical to the input — the CLI and SDK do not transform it.
  • The raw-REST repro reproduces the bug with td entirely out of the loop, so the fault is server-side: the due_string parser on POST /api/v1/tasks stores a starting clause inside the recurrence rule instead of resolving it into the due date.
  • The quick-add parser (the app's Add-task box, td task quickadd) resolves starting correctly. The two parsers should agree.

Expected

POST /api/v1/tasks with due_string: "every! 2 weeks starting <date>" should behave like quick-add: set the first due date to <date>, store the recurrence rule as every! 2 weeks, and advance normally on completion.

Workaround

Create the task with a bare recurrence, then position the first occurrence separately:

td task add "task" --due "every! 2 weeks"    # created due today
td task reschedule "task" 2026-06-20          # moves the first occurrence; recurrence preserved

td task reschedule keeps the recurrence rule intact, and the task advances correctly on completion thereafter.

Versions

  • td 1.63.0 (@doist/todoist-cli)
  • Endpoint: POST https://api.todoist.com/api/v1/tasks
  • Reproduces equally via direct REST with no td in the path (above)
  • macOS 26.5

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions