feat(sort): add task ordering that matches the Todoist apps - #480
feat(sort): add task ordering that matches the Todoist apps#480craigcarlyle wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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
Zunconditionally, ignoringtask.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. sortNeedsProjectsreturnsfalseforpriority,date,deadline,date-added, andname, but all of these can tie-break on project order viacompareDefault— either returntruefor everything exceptnoneor stop consultingprojectValuein the shared fallback.- The date-query heuristic in the filter string parser matches
search: today/search: dueas date-driven; strip or skipsearch:operands before applying the date pattern and add coverage. view-options.tshas no tests —findViewOptionsis pure and exported, and the null-object_id/is_deletedfiltering 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)
src/lib/task-sort.ts:118: The JSDoc says only
project,workspace, anddefaultsorts need the project list, but the function also returnstrueforassignee. That behavior is correct — assignee sorting needs the project map to resolve collaborator names (CollaboratorCache.preload->getUserNamelooks up the project to find its workspace/shared status) — so the comment understates the contract. A caller wiringsortNeedsProjectsper the docs (e.g. #479) could skip the project fetch for assignee sorts and silently leave every assignee unresolved. Update the comment to includeassignee.src/lib/api/view-options.ts:108:
SavedViewOptions.objectIdisstring | nullfor singleton views like Today and Upcoming, butfindViewOptionstypesobjectIdas a requiredstring. Updating the parameter toobjectId?: 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.src/lib/task-sort.ts:262:
timestampOfhand-rolls ISO-8601 parsing (a timezone-suffix regex plusDate.parsenormalization) thatdate-fns/parseISOalready provides. The repo depends on date-fns directly and usesparseISOinsrc/lib/dates.ts, so this is a duplicate capability against the existing convention.parseISOhandles date-only, offset, andZstrings, and for relative ordering it preserves the all-day-before-timed behavior this helper exists for; the invalid-input check becomesisNaN(parseISO(value).getTime()). Reusing it drops a custom parser and keeps date handling in one idiom.src/lib/api/view-options.ts:92: The comment says the failure breadcrumb appears behind
-v, butgetLogger().detail()logs at DETAIL level 2, which needs-vv;-v(INFO) won't show it. Either switch togetLogger().info()to match the comment, or correct the comment to-vv.src/lib/task-sort.ts:364:
sort.field === 'none'returns the originaltasksreference, 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].src/lib/task-sort.ts:368:
sortTasks' comparator re-parses date strings on every pairwise comparison.compareDefault/comparePrimarycalldueValue/deadlineValue/scheduleValue, and each call runs a regex plusDate.parseintimestampOf— 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.src/lib/task-sort.test.ts:384: The
formatTaskSorttest pins two exact display strings that are just a lookup overFIELD_LABELSin the same file — it duplicates the constants, covers only 2 of 10 fields, and breaks on wording changes rather than a real behavior regression. ThedefaultDirectionForandsortNeeds*assertions in the same block are similar low-signal lookup tests (defaultDirectionForis already exercised throughtaskSortFromViewOptions, and the interestingsortNeedsProjects('assignee')case isn't even checked). Consider keeping only the parse error-path tests, which have real signal.src/lib/task-sort.test.ts:251: The 'workspace' sort field is a user-facing option (listed in
TASK_SORT_FIELDS, mapped fromWORKSPACEinFIELD_BY_SORTED_BY) but has zero test coverage — neitherworkspaceValuenor the'workspace'case incomparePrimaryis exercised by any test.buildProjectOrdercreatesworkspaceIndexbut nosortTaskscall consumes it. If the workspace sort path silently producesNO_VALUEfor every task (e.g., becausesortNeedsProjects('workspace')regressed to false), no test would fail.
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>
Summary
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 ordersrc/lib/api/view-options.ts: reads the sorting saved on a view from theview_optionsSync resource/syncPOST instead ofapi.sync(), because the SDK schema typesobject_idas a required string while the API returnsnullfor the Today and Upcoming rows, so a typed sync throws for anyone who has customised either viewtoday,upcomingandlabel viewcan pick them up latertd filter viewwires them up in feat(filter): sort tasks the way the Todoist apps do #479, the next PR in this stackTest plan
view_optionspayload confirmed against a live account, including thenullobject_idrow that breaks the SDK