Skip to content

feat(sort): add task ordering that matches the Todoist apps - #480

Open
craigcarlyle wants to merge 2 commits into
mainfrom
craigcarlyle/task-sort
Open

feat(sort): add task ordering that matches the Todoist apps#480
craigcarlyle wants to merge 2 commits into
mainfrom
craigcarlyle/task-sort

Conversation

@craigcarlyle

@craigcarlyle craigcarlyle commented Aug 17, 2026

Copy link
Copy Markdown

Summary

  • Adds src/lib/task-sort.ts: Todoist's default ordering (priority, then date and time, then deadline, then project order, then task order within the project), the date-first variant that filters with date queries use, the named sorts, and sidebar project order
  • Adds src/lib/api/view-options.ts: reads the sorting saved on a view from the view_options Sync resource
  • Reads those options with a direct /sync POST instead of api.sync(), because the SDK schema types object_id as a required string while the API returns null for the Today and Upcoming rows, so a typed sync throws for anyone who has customised either view
  • Reversing a sort flips only the primary criterion, and the tasks Todoist parks at the bottom of an ascending list (no date, no assignee) move to the top, per the help article
  • Keeps both modules independent of any command so today, upcoming and label view can pick them up later
  • Nothing calls either module yet: td filter view wires them up in feat(filter): sort tasks the way the Todoist apps do #479, the next PR in this stack

Test plan

  • 32 new unit tests cover both default hierarchies, every named sort, direction reversal, project order and the date-query heuristic
  • Full suite passes on this branch alone (1827 tests)
  • Type-check, lint, format and SKILL.md sync clean
  • view_options payload confirmed against a live account, including the null object_id row that breaks the SDK

Todoist doesn't sort server-side. Every client sorts locally, first by the
sorting saved on the view and then, when the view has none, by a documented
default hierarchy. Neither piece existed in the CLI.

`src/lib/task-sort.ts` holds the ordering: both default hierarchies, the
named sorts, sidebar project order, and the check for whether a filter query
is date-driven. `src/lib/api/view-options.ts` reads the saved `view_options`
with a direct `/sync` POST rather than `api.sync()`, because the SDK schema
types `object_id` as a required string while the API returns null for the
Today and Upcoming rows.

Nothing calls either module yet. `td filter view` picks them up in the next
PR of the stack.

Refs #473

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@doistbot

doistbot commented Aug 17, 2026

Copy link
Copy Markdown
Member

⚠️ PR size is large: Review quality may be affected

👋 @craigcarlyle This PR is large enough that Doistbot's review may miss details.

Current diff: 916 review-load lines across 4 files (+916 / -1). I will still run the review, but this would be easier for your colleagues to review as smaller PRs or a PR stack 😅

ℹ️ To make it easier to review, the recommended diff size is < 750 review-load lines and < 25 files changed

To be mindful of their time I would suggest you split this PR

🪄 Suggested slicing plan 👇

Split the PR into a foundational Sync API module for view options followed by the task sorting algorithms and test suite that consume it.

PR order

  1. slice-1-feat-api-add-sync-view-options-client-mo → base main
  2. slice-2-feat-sort-implement-task-sorting-hierarc → base slice-1-feat-api-add-sync-view-options-client-mo

PR 1 feat(api): add sync view-options client module

Provide the API client utilities to fetch and parse saved view options from the Todoist Sync endpoint without triggering SDK schema validation errors.

Files (2):

  • CODEBASE.md
  • src/lib/api/view-options.ts

PR 2 feat(sort): implement task sorting hierarchies and test suite

Implement client-side task sorting matching Todoist default hierarchies, sidebar project ordering, date query detection heuristics, and comprehensive unit tests.

Files (3):

  • CODEBASE.md
  • src/lib/task-sort.ts
  • src/lib/task-sort.test.ts

This plan is based on the current PR head. Keep each slice buildable and move tests with the behavior they cover.

You can use your agent of choice (Codex/Claude etc) to help you split this PR 😊 Just copy the link to this comment and ask them Can you please create a PR stack based on the suggestions in this comment

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds two standalone modules — task-sort.ts for ordering tasks the way the Todoist apps do and view-options.ts for reading saved view options via a direct /sync call — with 32 new tests, ahead of the wiring in #479.

Few things worth tightening:

  • The floating-due-time conversion appends Z unconditionally, ignoring task.due.timezone; timed tasks in different IANA zones will sort in the wrong order — pass the due timezone through and resolve unzoned times in that zone, with a cross-timezone test.
  • sortNeedsProjects returns false for priority, date, deadline, date-added, and name, but all of these can tie-break on project order via compareDefault — either return true for everything except none or stop consulting projectValue in the shared fallback.
  • The date-query heuristic in the filter string parser matches search: today / search: due as date-driven; strip or skip search: operands before applying the date pattern and add coverage.
  • view-options.ts has no tests — findViewOptions is pure and exported, and the null-object_id / is_deleted filtering logic is exactly what the module exists for, so a small stubbed suite would add real regression protection.
  • The assignee sort is only tested ascending; add a descending case (e.g. ['unassigned', 'zoe', 'ana']) to guard the null-handling × reversal interaction.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (8)
  • P3 src/lib/task-sort.ts:118: The JSDoc says only project, workspace, and default sorts need the project list, but the function also returns true for assignee. That behavior is correct — assignee sorting needs the project map to resolve collaborator names (CollaboratorCache.preload -> getUserName looks up the project to find its workspace/shared status) — so the comment understates the contract. A caller wiring sortNeedsProjects per the docs (e.g. #479) could skip the project fetch for assignee sorts and silently leave every assignee unresolved. Update the comment to include assignee.
  • P3 src/lib/api/view-options.ts:108: SavedViewOptions.objectId is string | null for singleton views like Today and Upcoming, but findViewOptions types objectId as a required string. Updating the parameter to objectId?: string | null (e.g. { viewTypes, objectId = null }: { viewTypes: ViewType[]; objectId?: string | null }) will allow callers for Today and Upcoming views to query saved options without type errors.
  • P3 src/lib/task-sort.ts:262: timestampOf hand-rolls ISO-8601 parsing (a timezone-suffix regex plus Date.parse normalization) that date-fns/parseISO already provides. The repo depends on date-fns directly and uses parseISO in src/lib/dates.ts, so this is a duplicate capability against the existing convention. parseISO handles date-only, offset, and Z strings, and for relative ordering it preserves the all-day-before-timed behavior this helper exists for; the invalid-input check becomes isNaN(parseISO(value).getTime()). Reusing it drops a custom parser and keeps date handling in one idiom.
  • P3 src/lib/api/view-options.ts:92: The comment says the failure breadcrumb appears behind -v, but getLogger().detail() logs at DETAIL level 2, which needs -vv; -v (INFO) won't show it. Either switch to getLogger().info() to match the comment, or correct the comment to -vv.
  • P3 src/lib/task-sort.ts:364: sort.field === 'none' returns the original tasks reference, but every other branch returns [...tasks].sort(...). A caller that mutates the result (e.g. .push(), .reverse()) would accidentally mutate the input when the sort field is 'none'. The existing "does not mutate the input list" test only covers 'default', so this gap is unguarded. Trivial fix: return [...tasks].
  • P3 src/lib/task-sort.ts:368: sortTasks' comparator re-parses date strings on every pairwise comparison. compareDefault/comparePrimary call dueValue/deadlineValue/scheduleValue, and each call runs a regex plus Date.parse in timestampOf — so the same task's dates are parsed many times over the ~n log n comparisons (a 300-task page is ~2,500 comparisons). Precompute a sort key once per task (decorate-sort-undecorate) and have the comparator read the cached values.
  • P3 src/lib/task-sort.test.ts:384: The formatTaskSort test pins two exact display strings that are just a lookup over FIELD_LABELS in the same file — it duplicates the constants, covers only 2 of 10 fields, and breaks on wording changes rather than a real behavior regression. The defaultDirectionFor and sortNeeds* assertions in the same block are similar low-signal lookup tests (defaultDirectionFor is already exercised through taskSortFromViewOptions, and the interesting sortNeedsProjects('assignee') case isn't even checked). Consider keeping only the parse error-path tests, which have real signal.
  • P3 src/lib/task-sort.test.ts:251: The 'workspace' sort field is a user-facing option (listed in TASK_SORT_FIELDS, mapped from WORKSPACE in FIELD_BY_SORTED_BY) but has zero test coverage — neither workspaceValue nor the 'workspace' case in comparePrimary is exercised by any test. buildProjectOrder creates workspaceIndex but no sortTasks call consumes it. If the workspace sort path silently produces NO_VALUE for every task (e.g., because sortNeedsProjects('workspace') regressed to false), no test would fail.

Share FeedbackReview Logs

Comment thread src/lib/task-sort.ts Outdated
Comment thread src/lib/task-sort.ts Outdated
Comment thread src/lib/task-sort.ts
Comment thread src/lib/api/view-options.ts
Comment thread src/lib/task-sort.test.ts
Review follow-ups on the ordering modules.

`timestampOf` appended `Z` to floating datetimes and read date-only values as
UTC, so a task with a fixed timezone sorted by its wall-clock rather than the
instant it falls on. `date-fns/parseISO`, already the repo's date idiom, reads
date-only and floating values in the local zone and resolves zoned ones to a
real instant, which puts all three on one axis.

`sortNeedsProjects` now returns true for every field but `none`. Project order
is the fourth criterion of the default hierarchy and the default hierarchy is
the tie-break under every named sort, so skipping the fetch made equal-name
tasks order differently in `--json` than in the pretty output.

Also: the date heuristic no longer reads a `search:` operand ("search: due
diligence" is not a date query), `findViewOptions` accepts the null object id
the singleton views use, `--sort none` returns a copy rather than the caller's
array, and the `-v` breadcrumb comment says `-vv`, which is the level it logs at.

Adds a `view-options` suite plus cases for zoned datetimes, descending
assignee, workspace order and search operands.

Refs #473

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@craigcarlyle craigcarlyle self-assigned this Aug 17, 2026
@craigcarlyle craigcarlyle added the 🙋 Ask PR PR must be reviewed before merging label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🙋 Ask PR PR must be reviewed before merging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants