Skip to content

feat(tasks): add reusable task sorting - #666

Merged
craigcarlyle merged 3 commits into
ernesto/view-optionsfrom
ernesto/task-sorting
Aug 19, 2026
Merged

feat(tasks): add reusable task sorting#666
craigcarlyle merged 3 commits into
ernesto/view-optionsfrom
ernesto/task-sorting

Conversation

@gnapse

@gnapse gnapse commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a pure, non-mutating sortTasks utility that uses the existing SDK sorting types.
  • Implement Todoist default and named ordering, including date, timezone, priority, and manual tie-breaking behavior.
  • Accept consumer-supplied context for project and workspace ranks, assignee names, timezone, and locale.

Example

import { sortTasks } from "@doist/todoist-sdk"

const orderedTasks = sortTasks(
    tasks,
    {
        sortedBy: savedOptions?.sortedBy,
        sortOrder: savedOptions?.sortOrder,
        defaultOrder: "PRIORITY_FIRST",
    },
    { timezone: accountTimezone, projectOrder },
)

Validation

  • Used scripts importing the local SDK to sort tasks from a live Todoist test project using its saved options. The result placed earlier due dates first and undated tasks last without mutating the input.
  • Covered timezone-aware and floating dates, DST transitions, priority order, child-task order, manual tie-breaking, and immutability in local smoke and unit tests.
  • Tested todoist-cli#479 locally against a packed build of this SDK stack. Its full test suite passed.
  • The SDK test suite (52 files, 667 tests), build, and package API checks passed.

Context

This is the second PR in the stack and depends on #665. Consumers must fetch all pages before using it for globally correct sorting.

Related CLI work:

Stack created with GitHub Stacks CLIGive Feedback 💬

@doistbot

doistbot commented Aug 18, 2026

Copy link
Copy Markdown
Member

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

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

Current diff: 921 review-load lines across 3 files (+921 / -0). 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 two-part stack: first introducing the core task sorting engine with timezone-aware date parsing and default ordering hierarchies (PRIORITY_FIRST and DATE_FIRST), followed by named view-sorting criteria (alphabetical, assignee, date, priority, project, and workspace) with context resolvers.

PR order

  1. slice-1-feat-tasks-implement-default-task-orderi → base ernesto/view-options
  2. slice-2-feat-tasks-add-named-task-sort-criteria- → base slice-1-feat-tasks-implement-default-task-orderi

PR 1 feat(tasks): implement default task ordering and date sorting engine

Establishes the foundation of the task sorting utility with date parsing, timezone conversions, immutable task preparation, and default Todoist ordering hierarchies (PRIORITY_FIRST and DATE_FIRST).

Files (3):

  • src/utils/index.ts
  • src/utils/task-sorting.ts
  • src/utils/task-sorting.test.ts

PR 2 feat(tasks): add named task sort criteria and context-aware comparisons

Expands sortTasks to support explicit user-specified sorting fields (ALPHABETICALLY, ASSIGNEE, DUE_DATE, DEADLINE, ADDED_DATE, PRIORITY, PROJECT, WORKSPACE, MANUAL), custom sort directions, and context ranking resolvers.

Files (2):

  • src/utils/task-sorting.ts
  • src/utils/task-sorting.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.

Adds a reusable sortTasks utility with comprehensive sorting logic, tests, and proper string-union conventions.

Few things worth tightening:

  • Floating due times ignore task.due.timezone: When a task has a floating datetime (no Z/offset) and its due.timezone differs from the account timezone, the sort uses the wrong frame of reference. Convert the wall-clock time from task.due.timezone (falling back to the account timezone when null) into a common frame before computing the DateSortValue.
  • childOrder skipped for same-project tasks without projectOrder: When TaskSortContext is absent and two tasks share a project with equal priority/dates, manual order (childOrder) is not applied—tasks stay in input order instead. Use task.projectId equality to apply childOrder within a project while keeping the existing guard for cross-project tasks without ranks.

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

Optional follow-up notes (7)
  • P3 src/utils/task-sorting.test.ts:4: makeTask re-lists every Task field, duplicating the shared fixtures in src/test-utils/test-defaults.ts. TASK_WITH_OPTIONALS_AS_NULL already matches these defaults (null section/parent/deadline/duration/due, empty labels, priority 1), so this can be { ...TASK_WITH_OPTIONALS_AS_NULL, ...overrides } plus overrides for id/content/url. sanitization.test.ts already spreads DEFAULT_TASK this way; reusing the fixture keeps the Task shape in one place.
  • P3 src/utils/task-sorting.ts:189: The "null sorts last" guard is duplicated verbatim in compareDate, compareOptionalNumber, and compareOptionalText. Extract a single compareNullable(a, b, compare) helper and pass each field-specific comparator into it, so the same 4-line branch isn't maintained three times.
  • P3 src/utils/task-sorting.ts:337: direction is recomputed inside the sort comparator on every comparison (O(n log n) times), but it only depends on options.sortOrder and sortedBy, both fixed per call. Compute it once before .sort(...) and capture it in the closure.
  • P3 src/utils/task-sorting.ts:238: assigneeName is invoked for every task even when the result is never read — only the ASSIGNEE branch of comparePrimary uses assignee. For any other sortedBy (including the default hierarchies) this runs the user-supplied callback n times for nothing. Resolve it lazily, e.g. only when sortedBy === 'ASSIGNEE'.
  • P3 src/utils/task-sorting.test.ts:234: projectOrder and workspaceOrder contain the exact same keys and values, so this loop cannot tell which rank source each sortedBy actually reads. A swap in comparePrimary (WORKSPACE reading projectOrder, or PROJECT reading workspaceOrder) would still pass. Give the two maps different orders and assert that PROJECT and WORKSPACE produce different results.
  • P3 src/utils/task-sorting.test.ts:213: The three fields in this loop all order the tasks first/second/third in lockstep (due Jan/Feb/Mar, deadline Apr/May/Jun, addedAt Jan 1/2/3), so a copy-paste bug where DUE_DATE, DEADLINE, or ADDED_DATE compares the wrong attribute is undetectable. Use fixtures where at least one field disagrees on order, or assert a distinct expected order per field.
  • P3 src/utils/task-sorting.test.ts:432: expect(result[0]).toBe(result[2]) pins the fact that sortTasks returns the same object references, which is not part of the documented contract (only that it returns a new array). A future defensive copy of task objects would fail here for no behavioral reason. The stable-order behavior is already covered by the toEqual assertion; drop the identity check.

Share FeedbackReview Logs

Comment thread src/utils/task-sorting.ts Outdated
Comment thread src/utils/task-sorting.ts Outdated
@gnapse
gnapse force-pushed the ernesto/task-sorting branch from c0a5ee9 to 993aa7e Compare August 18, 2026 13:41
@gnapse

gnapse commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

I am keeping this as one PR. The sorter is one atomic module, and most of the review load is focused edge-case coverage. Splitting the implementation from its tests would create an artificial boundary. This PR is already the second layer of a stack.

@gnapse gnapse self-assigned this Aug 18, 2026
@gnapse
gnapse force-pushed the ernesto/task-sorting branch from 993aa7e to 249abd4 Compare August 18, 2026 16:33
@gnapse
gnapse force-pushed the ernesto/task-sorting branch from 249abd4 to 034c38e Compare August 18, 2026 17:53
@gnapse
gnapse requested a review from doistbot August 18, 2026 18:25

@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 a pure, non-mutating sortTasks utility that implements Todoist's default and named ordering (date, timezone, priority, manual) using consumer-supplied context for project/workspace ranks, assignee names, timezone, and locale.

Few things worth tightening:

  • When sorting fixed datetimes, preserve the original instant rather than reducing it to account-local wall-clock fields. During a DST fall-back hour, two distinct UTC times can map to the same local time in the wrong order, so keep an epoch-based key for timed values (handling date-only tasks separately) to maintain chronological correctness.

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

Optional follow-up notes (9)
  • P3 src/utils/task-sorting.ts:73: isValidDateParts and componentsAsUtcTimestamp hand-roll UTC timestamp construction with new Date(0) plus setUTCFullYear/setUTCHours. Date.UTC(year, month - 1, day, hour, minute, second, millisecond) does the same in one call, and reading back getUTCFullYear/getUTCMonth/getUTCDate still validates the calendar. Inputs always come from the (\d{4}) regex, so the 0-99 two-digit-year quirk does not apply.
  • P3 src/utils/task-sorting.test.ts:12: The task URL is rebuilt from the base URI literal instead of reusing getTaskUrl(overrides.id) from ./url-helpers. Other fixtures (todoist-api.tasks.test.ts, todoist-api.move-tasks.test.ts) construct URLs via getTaskUrl; hardcoding the domain here duplicates that source of truth.
  • P3 src/utils/task-sorting.ts:138: parseDateTimeComponents builds a throwaway DateSortValue only to reuse dateSortValue's validation, then discards it. Callers (parseFloatingDateTime, parseZonedFloatingDateTime) re-derive the value from the returned components, so the value is effectively constructed twice and the validity logic is split from the rest of the parsing. Extract the date-part and time-range checks into a small predicate shared by dateSortValue and parseDateTimeComponents instead.
  • P3 src/utils/task-sorting.ts:250: The four-way decision in parseTaskDate (date-only vs. explicit-offset fixed vs. zoned floating vs. plain floating) is a nested ternary chain that is easy to misread. Split it into an early-return if/else sequence (or a small switch) so each datetime format gets a distinct, self-explanatory branch.
  • P3 src/utils/task-sorting.ts:65: DATE_TIME_FORMATTERS is a process-lifetime, module-level Map keyed by consumer-supplied timezone strings — both context.timezone (line 411) and each task's due.timezone (line 257) — and it is never evicted or size-bounded. due.timezone is typed as plain string (not validated IANA), so in a long-running SDK consumer distinct values accumulate Intl.DateTimeFormat instances indefinitely. Bound the cache (LRU/max size) or cache only the account timezone and let per-task source timezones be transient.
  • P3 src/utils/task-sorting.ts:227: parseFixedDateTime runs parseDateTimeComponents purely to validate and then discards the result; that call extracts every field, builds a DateSortValue, and range-validates it. Date.parse and dateSortValueAt then re-parse and re-validate the same string. Use a regex-only format check (e.g. DATE_TIME_PATTERN.test(...)) so each fixed datetime is parsed once instead of twice.
  • P3 src/utils/task-sorting.ts:424: comparePrimary({ sortedBy, a, b, collator }) allocates a new object literal on every sort comparison — O(n log n) allocations that could be zero. Change comparePrimary to accept the four values as positional parameters instead of a destructured object, e.g. comparePrimary(sortedBy, a, b, collator). The call site is the sort comparator, so this runs for every pairwise comparison.
  • P3 src/utils/task-sorting.ts:175: componentsAt calls part() six times, each doing a full .find() scan of the formatToParts array. A single loop over parts that populates a lookup object (or Map) would do one pass instead of six. This function is called up to 5 times per zoned floating-date task (4 iterations in timestampForWallClock + 1 in dateSortValueAt).
  • P3 src/utils/task-sorting.ts:410: Intl.Collator is constructed for every sortTasks call, but it is only read for ALPHABETICALLY and ASSIGNEE sorting. Default, priority, date, project, and workspace sorts pay this setup cost unnecessarily. Construct it only for the two text-based sortedBy values (or lazily on first use).

Share FeedbackReview Logs

Comment thread src/utils/task-sorting.ts
@gnapse
gnapse force-pushed the ernesto/task-sorting branch from 034c38e to 146f13f Compare August 18, 2026 18:37
@gnapse
gnapse requested a review from craigcarlyle August 18, 2026 22:45

@craigcarlyle craigcarlyle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

gnapse and others added 3 commits August 19, 2026 12:21
Co-authored-by: Craig Carlyle <craigcarlyle@me.com>
Every other exported shape in src/ is declared with export type, so bring
TaskSortOptions and TaskSortContext in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@craigcarlyle
craigcarlyle merged commit a9464c1 into main Aug 19, 2026
5 checks passed
@craigcarlyle
craigcarlyle deleted the ernesto/task-sorting branch August 19, 2026 19:26
doist-release-bot Bot added a commit that referenced this pull request Aug 19, 2026
## [14.1.0](v14.0.2...v14.1.0) (2026-08-19)

### Features

* **tasks:** add reusable task sorting ([#666](#666)) ([a9464c1](a9464c1))
* **view-options:** add saved view options API ([#665](#665)) ([4caaa68](4caaa68))
@doist-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 14.1.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants